Enhance breadcrumb display (#12595)

* feat(breadcrumb): enhance breadcrumb functionality and styling

* feat(breadcrumb): enhance breadcrumb label handling and add tooltips

* feat(breadcrumb): add tooltip for breadcrumb search overflow

* fix lint and test

* feat(search): enhance subtitle handling for native search results

* fix doc

* feat(breadcrumb): add accessibility attributes to tooltip button

* fix: no need to pull refs for block parents

* enhance(breadcrumb): remove ref-resolution layer from parent breadcrumbs

---------

Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
This commit is contained in:
megayu
2026-05-08 17:14:13 +08:00
committed by GitHub
parent 08a479f2c8
commit 51f0ce261c
14 changed files with 1007 additions and 97 deletions

View File

@@ -637,6 +637,7 @@
(sort-by-order (:block/_parent parent)))))
(defn get-block-parents
"Returns parents entities"
[db block-id & {:keys [depth] :or {depth 100}}]
(loop [block-id block-id
parents' (list)

View File

@@ -10,6 +10,7 @@
[datascript.impl.entity :as e]
[dommy.core :as dom]
[electron.ipc :as ipc]
[frontend.components.block.breadcrumb-model :as breadcrumb-model]
[frontend.components.block.macros :as block-macros]
[frontend.components.icon :as icon-component]
[frontend.components.lazy-editor :as lazy-editor]
@@ -3064,33 +3065,36 @@
(not (dom/has-class? (gobj/get e "target") "bullet"))
(not @*dragging?)))
(defn- handle-breadcrumb-activate!
[config block opts e]
(cond
(gobj/get e "shiftKey")
(do
(util/stop e)
(state/sidebar-add-block!
(state/get-current-repo)
(:db/id block)
:block-ref))
(util/atom? (:navigating-block opts))
(do
(util/stop e)
(reset! (:navigating-block opts) (:block/uuid block)))
(some? (:sidebar-key config))
nil
:else
(when-let [uuid (:block/uuid block)]
(-> (or (:on-redirect-to-page config) route-handler/redirect-to-page!)
(apply [(str uuid)])))))
(rum/defc breadcrumb-fragment
[config block label opts]
[:a {:on-pointer-down (fn [e]
(when (some? (:sidebar-key config)) (util/stop e)))
:on-pointer-up
(fn [e]
(cond
(gobj/get e "shiftKey")
(do
(util/stop e)
(state/sidebar-add-block!
(state/get-current-repo)
(:db/id block)
:block-ref))
(util/atom? (:navigating-block opts))
(do
(util/stop e)
(reset! (:navigating-block opts) (:block/uuid block)))
(some? (:sidebar-key config))
nil
:else
(when-let [uuid (:block/uuid block)]
(-> (or (:on-redirect-to-page config) route-handler/redirect-to-page!)
(apply [(str uuid)])))))}
:on-pointer-up (fn [e]
(handle-breadcrumb-activate! config block opts e))}
label])
(rum/defc breadcrumb-separator
@@ -3098,69 +3102,217 @@
[:span.opacity-50.px-1
"/"])
(rum/defc breadcrumb-segment-label
"Renders the visual label (icon + text) for a breadcrumb segment.
Icon priority:
1. code/query/note/quote/math → always show their fixed structural icon
2. page/block with custom icon → get-node-icon-cp (shows custom icon)
3. empty block (nil text, no custom icon) → point-filled placeholder
4. regular page/block with text and no custom icon → no icon"
[seg entity]
(let [*label-ref (hooks/use-ref nil)
[truncated? set-truncated!] (hooks/use-state false)
text (:text seg)
seg-type (:type seg)
has-custom-icon? (some? (:icon seg))
;; Structural type icons — always present for code/query/note/quote
structural-icon (case seg-type
:code (shui/tabler-icon "code" {:size "12" :class "opacity-70"})
:query (shui/tabler-icon "search" {:size "12" :class "opacity-70"})
:note (shui/tabler-icon "notes" {:size "12" :class "opacity-70"})
:quote (shui/tabler-icon "quote" {:size "12" :class "opacity-70"})
:math (shui/tabler-icon "math-function" {:size "12" :class "opacity-70"})
nil)
node-icon (when (and (nil? structural-icon) entity has-custom-icon?)
(icon-component/get-node-icon-cp entity {}))
;; Placeholder for empty/untitled blocks with no text and no other icon
empty-placeholder (when (and (nil? structural-icon) (nil? node-icon) (nil? text))
(shui/tabler-icon "point-filled" {:size "12" :class "opacity-70"}))
icon-node (or structural-icon node-icon empty-placeholder)
non-blank (fn [s] (when-not (string/blank? s) s))
full-label (or (non-blank (:full-text seg))
(non-blank text))
set-label-ref! (hooks/use-callback (fn [el] (hooks/set-ref! *label-ref el)) [])]
(hooks/use-effect!
(fn []
(if (or (string/blank? text) (string/blank? full-label))
(do
(set-truncated! false)
nil)
(let [check! (fn []
(if-let [^js el (hooks/deref *label-ref)]
(set-truncated! (> (.-scrollWidth el) (.-clientWidth el)))
(set-truncated! false)))
resize-observer (when (some? (.-ResizeObserver js/window))
(js/ResizeObserver. check!))]
(check!)
(when-let [^js el (hooks/deref *label-ref)]
(when resize-observer
(.observe resize-observer el)
(when-let [parent (.-parentElement el)]
(.observe resize-observer parent))))
(.addEventListener js/window "resize" check!)
(fn []
(.removeEventListener js/window "resize" check!)
(when resize-observer
(.disconnect resize-observer))))))
[text full-label])
(let [inner [:span.breadcrumb__segment.inline-flex.items-center.min-w-0
{:aria-label (when-not text full-label)}
(when icon-node
[:span.breadcrumb__segment-icon.mr-0.5.shrink-0 icon-node])
(when text
[:span.breadcrumb__label {:ref set-label-ref!} text])]]
(if (and (not (string/blank? full-label)) truncated?)
(ui/tooltip inner full-label {:trigger-props {:as-child true}})
inner))))
(defn- breadcrumb-segments
[target-entity parents]
(let [raw-segments (mapv breadcrumb-model/block->breadcrumb-segment parents)
page-entity (when (and target-entity
(not (:page? (first raw-segments))))
(:block/page target-entity))
page-seg (when page-entity
(breadcrumb-model/block->breadcrumb-segment page-entity))]
(if page-seg
(into [page-seg] raw-segments)
raw-segments)))
(defn- breadcrumb-segment-entity
[seg]
(when (:db/id seg)
(db/entity (:db/id seg))))
(rum/defc breadcrumb-search-overflow-tooltip
[title]
(ui/tooltip
[:span.opacity-40.px-0.5.text-xs
{:role "button"
:tab-index 0
:aria-label (t :breadcrumb/more-ancestors)}
"···"]
title
{:trigger-props {:as-child true}}))
(rum/defcs breadcrumb-overflow-dropdown < (rum/local false ::open?)
(rum/local nil ::full-hidden)
"Renders an ellipsis button that exposes hidden ancestor segments in a dropdown."
[{open? ::open? full-hidden ::full-hidden}
config repo target-entity from-property hidden-segs opts vopts show-page?]
(let [hidden-segs' (or @full-hidden hidden-segs)
target-db-id (:db/id target-entity)
load-full-hidden! (fn []
(when (and target-db-id (nil? @full-hidden))
(p/let [parents (db-async/<get-block-parents repo target-db-id 1000)]
(let [parents (remove nil? (concat parents [from-property]))
segments (breadcrumb-segments target-entity parents)
view (breadcrumb-model/build-breadcrumb-view
segments
(assoc vopts :show-page? show-page?))]
(reset! full-hidden (:hidden view))))))]
(shui/dropdown-menu
{:open @open?
:on-open-change (fn [open]
(reset! open? open)
(when open (load-full-hidden!)))}
(ui/tooltip
(shui/dropdown-menu-trigger
{:as-child true}
[:button.breadcrumb__overflow.opacity-60.hover:opacity-100.px-0.5.text-xs
{:aria-label (t :breadcrumb/more-ancestors)}
"···"])
(t :breadcrumb/more-ancestors)
{:trigger-props {:as-child true}})
(when @open?
(shui/dropdown-menu-content
{:class "max-h-[min(50vh,420px)] overflow-y-auto"}
(for [seg hidden-segs']
(let [entity (breadcrumb-segment-entity seg)
label (breadcrumb-segment-label seg entity)
nav-block (or entity
{:db/id (:db/id seg)
:block/uuid (:block/uuid seg)})]
(shui/dropdown-menu-item
{:key (str (:block/uuid seg))
:on-click (when-not (:disabled? opts)
#(handle-breadcrumb-activate! config nav-block opts %))}
label))))))))
;; "block-id - uuid of the target block of breadcrumb. page uuid is also acceptable"
(rum/defc breadcrumb-aux < rum/reactive
[config repo block-id {:keys [show-page? indent? end-separator? _navigating-block disabled?]
[config repo block-id {:keys [show-page? indent? end-separator? _navigating-block disabled? variant header?]
:or {show-page? true}
:as opts}]
(let [from-property (when block-id
(:logseq.property/created-from-property (db/entity [:block/uuid block-id])))
parents (db/get-block-parents repo block-id {:depth 1000})
parents (cond-> (remove nil? (concat parents [from-property]))
(not show-page?)
rest)
(let [;; Derive effective variant from explicit :variant opt or legacy config flags
effective-variant (or variant
(cond
header? :app-header
(:search? config) :search-result
(:list-view? config) :inline
:else :block-page))
vopts (breadcrumb-model/variant-options effective-variant)
load-depth (:load-depth vopts)
target-entity (when block-id (db/entity [:block/uuid block-id]))
from-property (when block-id
(:logseq.property/created-from-property target-entity))
parents (db/get-block-parents repo block-id {:depth load-depth})
parents (remove nil? (concat parents [from-property]))
segments (breadcrumb-segments target-entity parents)
view (breadcrumb-model/build-breadcrumb-view segments (assoc vopts :show-page? show-page?))
{visible-prefix-raw :visible-prefix hidden :hidden visible-suffix-raw :visible-suffix overflow? :overflow?} view
full-title (breadcrumb-model/segments->full-title
(concat visible-prefix-raw hidden visible-suffix-raw))
config (assoc config
:breadcrumb? true
:disable-preview? true)]
(when (seq parents)
(let [parents-props (doall
(for [{:block/keys [uuid name] :as block} parents]
(if name
[block (page-cp (cond-> {:disable-preview? true}
disabled?
(assoc :disable-click? true))
block) true]
(let [result (block/parse-title-and-body
uuid
(get block :block/format :markdown)
(:block/raw-title block))
ast-body (:block.temp/ast-body result)
ast-title (:block.temp/ast-title result)
config (assoc config :block/uuid uuid)]
[block
(when ast-title
(if (seq ast-title)
(->elem :span (map-inline config ast-title))
(->elem :div (markup-elements-cp config ast-body))))
false]))))
breadcrumbs (->> parents-props
(map (fn [x]
(let [[block label page?] x
label' (if page?
label
(breadcrumb-fragment config block label opts))]
(if (:disabled? opts)
label
(rum/with-key label' (str (:block/uuid block)))))))
(interpose (breadcrumb-separator)))]
(when (seq breadcrumbs)
[:div.breadcrumb.block-parents
{:class (when (seq breadcrumbs)
(str (when-not (or (:search? config) (:list-view? config))
" my-2")
(when indent?
" ml-4")))}
(when (and (false? (:top-level? config))
(seq parents))
(breadcrumb-separator))
breadcrumbs
(when end-separator? (breadcrumb-separator))])))))
:disable-preview? true)
render-seg (fn [seg]
(let [entity (breadcrumb-segment-entity seg)
label (breadcrumb-segment-label seg entity)
nav-block (or entity
{:db/id (:db/id seg)
:block/uuid (:block/uuid seg)})]
(rum/with-key
(if (or disabled? (= effective-variant :search-result))
label
(breadcrumb-fragment config nav-block label opts))
(str (:block/uuid seg)))))]
(when (or (seq visible-prefix-raw) (seq visible-suffix-raw))
[:div.breadcrumb.block-parents
{:class (str " breadcrumb--" (name effective-variant)
(when-not (or (:search? config) (:list-view? config)) " my-2")
(when indent? " ml-4"))}
(when (and (false? (:top-level? config)) (seq parents))
(breadcrumb-separator))
;; visible prefix (page + early ancestors)
(interpose (breadcrumb-separator) (map render-seg visible-prefix-raw))
;; overflow indicator
(when overflow?
(list
(breadcrumb-separator)
(if (= effective-variant :search-result)
(breadcrumb-search-overflow-tooltip full-title)
(breadcrumb-overflow-dropdown
config repo target-entity from-property hidden opts vopts show-page?))))
;; visible suffix (nearest parents)
(when (seq visible-suffix-raw)
(list
(breadcrumb-separator)
(interpose (breadcrumb-separator) (map render-seg visible-suffix-raw))))
(when end-separator? (breadcrumb-separator))])))
(rum/defc breadcrumb
[config repo block-id {:keys [_show-page? _indent? _end-separator? _navigating-block _disabled?]
[config repo block-id {:keys [_show-page? _indent? _end-separator? _navigating-block _disabled? variant header?]
:as opts}]
(let [[block set-block!] (hooks/use-state (when (uuid? block-id)
(db/entity [:block/uuid block-id])))]
(db/entity [:block/uuid block-id])))
effective-variant (or variant
(cond
header? :app-header
(:search? config) :search-result
(:list-view? config) :inline
:else :block-page))
load-depth (:load-depth (breadcrumb-model/variant-options effective-variant))]
(hooks/use-effect!
(fn []
(p/let [block (db-async/<get-block (state/get-current-repo)
@@ -3168,7 +3320,7 @@
{:children? false
:skip-refresh? true})
_ (when-let [id (:db/id block)]
(db-async/<get-block-parents (state/get-current-repo) id 9))]
(db-async/<get-block-parents (state/get-current-repo) id load-depth))]
(set-block! block)))
[])
(when block

View File

@@ -111,6 +111,71 @@
}
}
/* Breadcrumb variant layout — stable single-line path summary */
.breadcrumb.block-parents {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
flex-wrap: nowrap;
}
.breadcrumb__segment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: var(--breadcrumb-segment-max-width, 28ch);
vertical-align: middle;
}
.breadcrumb__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.breadcrumb__segment-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-right: 2px;
}
.breadcrumb__overflow {
cursor: pointer;
flex-shrink: 0;
}
/* Variant-specific max-width budgets */
.breadcrumb--block-page {
--breadcrumb-segment-max-width: 28ch;
}
.breadcrumb--search-result {
--breadcrumb-segment-max-width: 18ch;
}
.breadcrumb--app-header {
--breadcrumb-segment-max-width: 28ch;
}
.breadcrumb--inline {
--breadcrumb-segment-max-width: 22ch;
}
@media (max-width: 640px) {
.breadcrumb--block-page,
.breadcrumb--search-result,
.breadcrumb--app-header,
.breadcrumb--inline {
--breadcrumb-segment-max-width: 14ch;
}
}
.open-block-ref-link {
background-color: var(--ls-page-properties-background-color);
padding: 1px 4px;

View File

@@ -0,0 +1,290 @@
(ns frontend.components.block.breadcrumb-model
"Pure logic for breadcrumb segment model and visibility algorithm.
No rendering, no component dependencies.
Converts page/block entities into flat segment maps, then applies a
display-budget algorithm to determine which segments are visible and
which are folded into an ellipsis."
(:require [clojure.string :as string]))
;; ---------------------------------------------------------------------------
;; Text normalization
;; ---------------------------------------------------------------------------
(def ^:private max-segment-text-length 160)
(defn- truncate-segment-text
[text]
(when-not (string/blank? text)
(if (> (count text) max-segment-text-length)
(str (subs text 0 max-segment-text-length) "…")
text)))
(defn- normalized-lines
"Returns trimmed non-empty lines from a string."
[s]
(when (string? s)
(->> (string/split-lines s)
(map string/trim)
(remove string/blank?))))
(defn- markdown-fence-line?
[line]
(boolean (re-matches #"^\s*```.*" line)))
(defn- org-boundary-line?
[line]
(boolean (re-matches #"(?i)^\s*#\+(BEGIN|END)_\w+.*" line)))
(defn- query-line?
[line]
(boolean (re-matches #"(?is)^\s*\{\{query\b.*\}\}\s*$" line)))
(defn- structural-marker-line?
[line]
(or (markdown-fence-line? line)
(org-boundary-line? line)
(query-line? line)))
(defn- strip-code-comment-marker
[line]
(when-let [[_ text] (re-matches #"^\s*(?:;;|//|#|--)\s+(.+)$" line)]
text))
(defn- useful-code-line?
[line]
(some? (strip-code-comment-marker line)))
(defn- useful-query-line?
[line]
(not (query-line? line)))
(defn- strip-markdown-markup
"Removes common markdown markup from a line, returning plain text."
[line]
(-> line
;; headings: ## Title -> Title
(string/replace #"^\s*#{1,6}\s+" "")
;; bold: **text** or __text__
(string/replace #"\*\*(.+?)\*\*" "$1")
(string/replace #"__(.+?)__" "$1")
;; italic: *text* or _text_ (simple non-greedy)
(string/replace #"\*([^*]+?)\*" "$1")
(string/replace #"_([^_]+?)_" "$1")
;; inline code: `code`
(string/replace #"`(.+?)`" "$1")
;; links: [text](url)
(string/replace #"\[(.+?)\]\(.+?\)" "$1")
;; query block wrapper
(string/replace #"\{\{query[^}]*\}\}" "")
;; org-mode begin/end markers
(string/replace #"(?i)#\+BEGIN_\w+" "")
(string/replace #"(?i)#\+END_\w+" "")
string/trim))
(defn normalize-breadcrumb-text
"Extracts a short plain-text label from block raw-title.
Returns at most max-segment-text-length characters of the first
non-empty line (plus a trailing ellipsis if truncated).
Does NOT invoke mldoc parse or markup rendering."
[raw-title]
(when (string? raw-title)
(let [line (some (fn [line]
(when-not (structural-marker-line? line)
line))
(normalized-lines raw-title))
cleaned (when line (strip-markdown-markup line))]
(truncate-segment-text cleaned))))
(defn- normalize-typed-breadcrumb-text
[raw-title block-type]
(when (string? raw-title)
(let [line (case block-type
:code
(some (fn [line]
(when (useful-code-line? line)
(strip-code-comment-marker line)))
(normalized-lines raw-title))
:query
(some (fn [line]
(when (and (not (structural-marker-line? line))
(useful-query-line? line))
line))
(normalized-lines raw-title))
(:note :quote)
(some (fn [line]
(when-not (structural-marker-line? line)
line))
(normalized-lines raw-title))
(normalize-breadcrumb-text raw-title))
cleaned (when line (strip-markdown-markup line))]
(truncate-segment-text cleaned))))
;; ---------------------------------------------------------------------------
;; Block type detection
;; ---------------------------------------------------------------------------
(defn detect-block-type
"Detects the semantic type of a block based on its raw title/content.
Returns :page, :code, :query, :note, :quote, or :block."
[raw-title page?]
(if page?
:page
(when (string? raw-title)
(let [trimmed (string/trim raw-title)]
(cond
(or (string/starts-with? trimmed "```")
(re-find #"(?i)#\+BEGIN_SRC" trimmed))
:code
(some query-line? (normalized-lines raw-title))
:query
(re-find #"(?i)#\+BEGIN_NOTE" trimmed)
:note
(re-find #"(?i)#\+BEGIN_QUOTE" trimmed)
:quote
:else
:block)))))
;; ---------------------------------------------------------------------------
;; Segment model
;; ---------------------------------------------------------------------------
;; DB Query class idents — standard Query plus Cards (which extends Query).
(def ^:private query-class-idents
#{:logseq.class/Query :logseq.class/Cards})
(defn- entity-tags->block-type
"Returns :query if entity has a Query-family tag (:logseq.class/Query or
:logseq.class/Cards). Returns nil otherwise."
[entity]
(when (some (fn [t]
(query-class-idents (or (:db/ident t) t)))
(:block/tags entity))
:query))
(defn- display-type->block-type
"Maps a DB :logseq.property.node/display-type keyword to a breadcrumb type.
Returns nil when display-type is not a recognised structural type."
[display-type]
(case display-type
:code :code
:quote :quote
:math :math
nil))
(defn block->breadcrumb-segment
"Converts a page or block entity map to a breadcrumb segment map.
Segment keys:
:db/id - DataScript entity id
:block/uuid - block uuid
:type - :page | :block | :code | :query | :note | :quote | :math
:text - short plain-text label (may be nil for empty blocks)
:full-text - full normalized single-line text for title/aria-label
:icon - icon value from :logseq.property/icon, or nil
:page? - true for page segments"
[entity]
(when entity
(let [page? (some? (:block/name entity))
raw-title (or (:block/raw-title entity) (:block/title entity))
;; DB version: structural type is stored in :logseq.property.node/display-type
;; (Code/Quote/Math blocks) or inferred from :block/tags (Query family).
;; org-mode markers like #+BEGIN_SRC in raw-title are also recognised as
;; a valid fallback since that rendering form is still supported.
db-display-type (display-type->block-type (:logseq.property.node/display-type entity))
tag-type (when-not page? (entity-tags->block-type entity))
block-type (or db-display-type tag-type (detect-block-type raw-title page?))
text (if page?
(or (:block/title entity) (:block/name entity))
(normalize-typed-breadcrumb-text raw-title block-type))
full-text (if page?
(or (:block/title entity) (:block/name entity) "")
(or text ""))
icon (:logseq.property/icon entity)]
{:db/id (:db/id entity)
:block/uuid (:block/uuid entity)
:type block-type
:text text
:full-text full-text
:icon icon
:page? page?})))
(defn segments->full-title
"Joins segment texts into a / separated path string for use in
title attribute or aria-label."
[segments]
(->> segments
(map #(or (:text %) (:full-text %)))
(remove string/blank?)
(string/join " / ")))
;; ---------------------------------------------------------------------------
;; Variant options
;; ---------------------------------------------------------------------------
(def ^:private variant-defaults
{:block-page {:max-visible 4 :nearest-count 2 :load-depth 16}
:app-header {:max-visible 4 :nearest-count 1 :load-depth 4}
:search-result {:max-visible 3 :nearest-count 1 :load-depth 4}
:inline {:max-visible 3 :nearest-count 1 :load-depth 8}
:native-search {:max-visible 2 :nearest-count 1 :load-depth 3}})
(defn variant-options
"Returns display options for a given variant keyword.
Falls back to :inline defaults if variant is unknown.
Merges with any extra-opts map provided."
[variant & [extra-opts]]
(merge (get variant-defaults variant (:inline variant-defaults))
extra-opts))
;; ---------------------------------------------------------------------------
;; Visibility algorithm
;; ---------------------------------------------------------------------------
(defn build-breadcrumb-view
"Applies the visibility budget to a sequence of segments.
`segments` are ordered from root (page) to nearest parent — the same
order returned by db/get-block-parents (root first).
Options:
:show-page? - include the page segment (default true)
:max-visible - max total visible segments (default 4)
:nearest-count - always show this many nearest-parent segments (default 2)
Returns:
{:visible-prefix [seg ...] ; first segments that fit (includes page)
:hidden [seg ...] ; middle segments folded into ellipsis
:visible-suffix [seg ...] ; nearest parent(s) always visible
:overflow? bool} ; true when any segments are hidden"
[segments {:keys [show-page? max-visible nearest-count]
:or {show-page? true max-visible 4 nearest-count 2}}]
(let [segs (if show-page? (vec segments) (vec (rest segments)))
total (count segs)]
(cond
(zero? total)
{:visible-prefix [] :hidden [] :visible-suffix [] :overflow? false}
(<= total max-visible)
{:visible-prefix segs :hidden [] :visible-suffix [] :overflow? false}
:else
;; Strategy:
;; - Prefix: keep the first segment (page or earliest ancestor)
;; - Suffix: keep the nearest `nearest-count` segments
;; - Hidden: everything in between
(let [keep-first 1
suffix-count (min nearest-count (- total keep-first))
prefix (vec (take keep-first segs))
suffix (vec (take-last suffix-count segs))
hidden (vec (subvec segs keep-first (- total suffix-count)))]
{:visible-prefix prefix
:hidden hidden
:visible-suffix suffix
:overflow? true}))))

View File

@@ -49,6 +49,21 @@
margin-inline: 2px;
}
/* Cmd+K result row — header path is single-line, main text limited to 2 lines */
[data-cmdk-item] .breadcrumb.block-parents {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
[data-cmdk-item] .cp__cmdk-item-main-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
flex-wrap: nowrap;
}
/* Keyboard navigation highlight */
[data-cmdk-item][data-kb-highlighted] {
background-color: var(--lx-gray-03, var(--ls-a-chosen-bg, var(--ls-tertiary-background-color, rgba(0, 0, 0, 0.10))));

View File

@@ -362,7 +362,7 @@
:header (when (:block/parent entity)
(block/breadcrumb {:disable-preview? true
:search? true} repo (:block/uuid page)
{:disabled? true}))
{:disabled? true :variant :search-result}))
:result-type :page
:current-page? current-page?
:alias (:alias page)
@@ -378,7 +378,7 @@
:text (highlight-content-query text input)
:header (block/breadcrumb {:disable-preview? true
:search? true} repo id
{:disabled? true})
{:disabled? true :variant :search-result})
:result-type :block
:current-page? (when-let [page-id (:block/page block)]
(= page-id current-page-uuid))
@@ -515,7 +515,8 @@
{:icon "node"
:icon-theme :gray
:text (highlight-content-query (:block/title block) @!input)
:header (block/breadcrumb {:search? true} repo id {:disabled? true})
:header (block/breadcrumb {:search? true} repo id
{:disabled? true :variant :search-result})
:result-type (if (:page? block) :page :block)
:current-page? true
:source-block block})) blocks)]

View File

@@ -87,10 +87,12 @@
:ref (when on-mounted on-mounted)
:on-click (when on-click on-click)
:on-mouse-move (when on-mouse-move on-mouse-move)})
;; header
;; header — single-line, overflow hidden so long paths don't push content off screen
(when header
[:div.text-xs.pl-8.font-light.flex.items-center.gap-2.flex-wrap {:class "-mt-1"
:style {:color "var(--lx-gray-11)"}}
[:div.text-xs.pl-8.font-light.flex.items-center.gap-2.overflow-hidden.min-w-0 {:class "-mt-1"
:style {:color "var(--lx-gray-11)"
:white-space "nowrap"
:text-overflow "ellipsis"}}
(highlight-query header)
header-badge])
;; main row
@@ -110,7 +112,7 @@
[:div.flex.flex-1.flex-col
(when title
[:div.text-sm.pb-2.font-bold.text-gray-11 (highlight-query title)])
[:div {:class "text-sm font-medium text-gray-12 flex items-center gap-2 flex-wrap"}
[:div {:class "cp__cmdk-item-main-text text-sm font-medium text-gray-12 flex items-center gap-2 flex-wrap"}
(block-handler/block-title-with-icon source-block
(highlight-query text)
icon-component/icon)

View File

@@ -44,12 +44,14 @@
(rum/defc home-button
< {:key-fn #(identity "home-button")}
[]
(shui/button-ghost-icon :home
{:title (t :nav/home)
:on-click #(do
(when (mobile-util/native-iphone?)
(state/set-left-sidebar-open! false))
(route-handler/redirect-to-home!))}))
(ui/tooltip
(shui/button-ghost-icon :home
{:on-click #(do
(when (mobile-util/native-iphone?)
(state/set-left-sidebar-open! false))
(route-handler/redirect-to-home!))})
(t :nav/home)
{:trigger-props {:as-child true}}))
(rum/defcs rtc-collaborators <
rum/reactive

View File

@@ -725,7 +725,10 @@
(when-let [conn (worker-state/get-datascript-conn repo)]
(let [block-id (:block/uuid (d/entity @conn id))]
(->> (ldb/get-block-parents @conn block-id {:depth (or depth 3)})
(map (fn [b] (d/pull @conn '[*] (:db/id b))))))))
(map (fn [b]
(-> (into {} b)
(assoc :db/id (:db/id b)
:block/title (:block/title b)))))))))
(def-thread-api :thread-api/set-context
[context]

View File

@@ -1,8 +1,9 @@
(ns logseq.db-worker.server-list
"Helpers for the centralized db-worker-node server-list file."
(:require [clojure.string :as string]
["fs" :as fs]
["path" :as node-path]))
(:require ["fs" :as fs]
["path" :as node-path]
[clojure.string :as string]
[logseq.common.path :as path]))
(def ^:private write-lock-timeout-ms 2000)
(def ^:private write-lock-poll-interval-ms 25)
@@ -11,13 +12,13 @@
[root-dir-path]
(when-not (seq root-dir-path)
(throw (js/Error. "root-dir is required")))
(node-path/join root-dir-path "server-list"))
(path/path-join root-dir-path "server-list"))
(defn lock-path
[file-path]
(when-not (seq file-path)
(throw (js/Error. "server-list file path is required")))
(node-path/join (node-path/dirname file-path) "server-list.lock"))
(path/path-join (node-path/dirname file-path) "server-list.lock"))
(defn- parse-int
[value]

View File

@@ -1,6 +1,7 @@
(ns mobile.search
"Mobile search"
(:require [clojure.string :as string]
[frontend.components.block.breadcrumb-model :as breadcrumb-model]
[frontend.components.cmdk.core :as cmdk]
[frontend.db :as db]
[frontend.search :as search]
@@ -33,6 +34,36 @@
:else nil)]
(:block/title page-block)))
(defn- block->nearest-parent-text
"Returns the text of the nearest non-page parent block, or nil."
[block]
(let [parent (:block/parent block)
parent-entity (cond
(map? parent) parent
(number? parent) (db/entity parent)
:else nil)]
(when (and parent-entity (not (:block/name parent-entity)))
(:text (breadcrumb-model/block->breadcrumb-segment parent-entity)))))
(def ^:private native-subtitle-max-length 96)
(defn- build-native-subtitle
"Builds a subtitle string of the form 'Page' or 'Page / Parent' for native search.
Safely truncated to native-subtitle-max-length."
[block]
(let [page-name (block->page-name block)
parent-text (block->nearest-parent-text block)
subtitle (cond
(and (not (string/blank? page-name))
(not (string/blank? parent-text)))
(str page-name " / " parent-text)
(not (string/blank? page-name)) page-name
:else parent-text)]
(when-not (string/blank? subtitle)
(if (> (count subtitle) native-subtitle-max-length)
(str (subs subtitle 0 native-subtitle-max-length) "…")
subtitle))))
(defn safe-truncate [s]
(if (<= (count s) 256)
s
@@ -43,7 +74,7 @@
(let [block (:source-block item)
id (:block/uuid block)
title (some-> block :block.temp/original-title string/trim)
subtitle (some-> block block->page-name string/trim)]
subtitle (build-native-subtitle block)]
(when (and id (not (string/blank? title)))
(let [short-title (when title (safe-truncate title))]
{:id (str id)

View File

@@ -114,6 +114,8 @@
:block.reaction/emoji-required-warning "Please pick an emoji reaction."
:block.reaction/unsupported-emoji-warning "Unsupported reaction emoji."
:breadcrumb/more-ancestors "Show more ancestors"
:bug-report/desc "Can you help us out by submitting a bug report? We'll get it sorted out as soon as we can."
:bug-report/title "Bug report"

View File

@@ -111,6 +111,8 @@
:block.reaction/emoji-required-warning "请先选择一个表情回应。"
:block.reaction/unsupported-emoji-warning "不支持该表情回应。"
:breadcrumb/more-ancestors "显示更多上级节点"
:bug-report/desc "你可以帮助我们提交一个错误报告吗?我们会尽快解决。"
:bug-report/title "BUG 上报"

View File

@@ -0,0 +1,343 @@
(ns frontend.components.block.breadcrumb-model-test
(:require [cljs.test :refer [deftest is testing]]
[clojure.string :as string]
[frontend.components.block.breadcrumb-model :as model]))
;; ---------------------------------------------------------------------------
;; normalize-breadcrumb-text
;; ---------------------------------------------------------------------------
(deftest normalize-breadcrumb-text-test
(testing "returns nil for nil input"
(is (nil? (model/normalize-breadcrumb-text nil))))
(testing "returns nil for empty string"
(is (nil? (model/normalize-breadcrumb-text ""))))
(testing "returns nil for blank string"
(is (nil? (model/normalize-breadcrumb-text " "))))
(testing "returns first non-empty line from multi-line input"
(is (= "First line" (model/normalize-breadcrumb-text "First line\nSecond line"))))
(testing "skips leading blank lines"
(is (= "Hello" (model/normalize-breadcrumb-text "\n\nHello\nWorld"))))
(testing "strips markdown bold markers"
(is (= "bold text" (model/normalize-breadcrumb-text "**bold text**"))))
(testing "strips markdown italic markers"
(is (= "italic" (model/normalize-breadcrumb-text "*italic*"))))
(testing "strips heading markers"
(is (= "Heading" (model/normalize-breadcrumb-text "## Heading"))))
(testing "strips inline code markers"
(is (= "code" (model/normalize-breadcrumb-text "`code`"))))
(testing "strips markdown link to text only"
(is (= "link text" (model/normalize-breadcrumb-text "[link text](https://example.com)"))))
(testing "truncates text over 160 chars with ellipsis"
(let [long-text (apply str (repeat 200 "a"))
result (model/normalize-breadcrumb-text long-text)]
(is (= 161 (count result))) ;; 160 chars + "…" (1 char)
(is (string/ends-with? result "…"))))
(testing "skips markdown fence markers"
(is (nil? (model/normalize-breadcrumb-text "```clojure\n```"))))
(testing "skips org begin/end markers and returns inner text"
(is (= "Release checklist"
(model/normalize-breadcrumb-text "#+BEGIN_NOTE\nRelease checklist\n#+END_NOTE"))))
(testing "skips standalone query expressions"
(is (nil? (model/normalize-breadcrumb-text "{{query (task TODO)}}"))))
(testing "returns text as-is when under 160 chars"
(is (= "short" (model/normalize-breadcrumb-text "short")))))
;; ---------------------------------------------------------------------------
;; detect-block-type
;; ---------------------------------------------------------------------------
(deftest detect-block-type-test
(testing "page? true always returns :page"
(is (= :page (model/detect-block-type "anything" true))))
(testing "detects code block by markdown fence"
(is (= :code (model/detect-block-type "```clojure\n(foo)" false))))
(testing "detects code block by org-mode src"
(is (= :code (model/detect-block-type "#+BEGIN_SRC clojure\n(foo)" false))))
(testing "detects query block"
(is (= :query (model/detect-block-type "{{query (task TODO)}}" false))))
(testing "detects note block"
(is (= :note (model/detect-block-type "#+BEGIN_NOTE\nsome note" false))))
(testing "detects quote block"
(is (= :quote (model/detect-block-type "#+BEGIN_QUOTE\nsome quote" false))))
(testing "returns :block for plain text"
(is (= :block (model/detect-block-type "Hello world" false))))
(testing "returns nil for nil raw-title when not page"
(is (nil? (model/detect-block-type nil false)))))
;; ---------------------------------------------------------------------------
;; block->breadcrumb-segment — basic shape
;; ---------------------------------------------------------------------------
(deftest block->breadcrumb-segment-basic-test
(testing "returns nil for nil entity"
(is (nil? (model/block->breadcrumb-segment nil))))
(testing "page entity has :type :page and :page? true"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/name "my-page"
:block/title "My Page"})]
(is (= :page (:type seg)))
(is (true? (:page? seg)))
(is (= "My Page" (:text seg)))))
(testing "plain block has :type :block"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "Hello world"})]
(is (= :block (:type seg)))
(is (false? (:page? seg)))
(is (= "Hello world" (:text seg)))))
(testing "empty block has nil :text but non-nil full-text empty string"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title ""})]
(is (nil? (:text seg)))
(is (= "" (:full-text seg)))))
(testing "block with icon preserves icon value"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "Decorated"
:logseq.property/icon {:type :emoji :id "🌟"}})]
(is (= {:type :emoji :id "🌟"} (:icon seg)))))
(testing "plain block text is unchanged"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "plain text"})]
(is (= "plain text" (:text seg))))))
;; ---------------------------------------------------------------------------
;; block->breadcrumb-segment — structural type detection
;; ---------------------------------------------------------------------------
(deftest ^:large-vars/cleanup-todo block->breadcrumb-segment-type-test
(testing "code block entity has :type :code via raw-title fence"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "```clojure\n(foo bar)"})]
(is (= :code (:type seg)))))
(testing "DB code block entity has :type :code via display-type property"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "(foo bar)"
:logseq.property.node/display-type :code})]
(is (= :code (:type seg)))))
(testing "DB quote block entity has :type :quote via display-type property"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "some quote"
:logseq.property.node/display-type :quote})]
(is (= :quote (:type seg)))))
(testing "DB math block entity maps to :type :math via display-type property"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "e=mc^2"
:logseq.property.node/display-type :math})]
(is (= :math (:type seg)))
(is (= "e=mc^2" (:text seg)))))
(testing "display-type takes precedence over raw-title pattern"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "{{query (task TODO)}}"
:logseq.property.node/display-type :code})]
(is (= :code (:type seg)))))
(testing "query block entity has :type :query"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "{{query (task TODO)}}"})]
(is (= :query (:type seg)))
(is (nil? (:text seg)))))
(testing "query block with a title keeps title as usable summary"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "Open tasks\n{{query (task TODO)}}"})]
(is (= :query (:type seg)))
(is (= "Open tasks" (:text seg)))))
(testing "note block skips wrapper and uses first inner text"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "#+BEGIN_NOTE\nRelease checklist\n#+END_NOTE"})]
(is (= :note (:type seg)))
(is (= "Release checklist" (:text seg)))))
(testing "quote block skips wrapper and uses first inner text"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "#+BEGIN_QUOTE\nWe shape our tools\n#+END_QUOTE"})]
(is (= :quote (:type seg)))
(is (= "We shape our tools" (:text seg)))))
(testing "code block without a useful comment has icon-only summary"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "```clojure\n(defn foo [])\n```"})]
(is (= :code (:type seg)))
(is (nil? (:text seg)))))
(testing "code block can use a short comment as summary"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "```clojure\n;; Parse breadcrumbs\n(defn foo [])\n```"})]
(is (= :code (:type seg)))
(is (= "Parse breadcrumbs" (:text seg)))))
(testing "DB query block detected via :logseq.class/Query tag"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "some query content"
:block/tags [{:db/ident :logseq.class/Query}]})]
(is (= :query (:type seg)))))
(testing "DB Cards block detected via :logseq.class/Cards tag"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "cards content"
:block/tags [{:db/ident :logseq.class/Cards}]})]
(is (= :query (:type seg)))))
(testing "tag-type is overridden by display-type"
(let [seg (model/block->breadcrumb-segment
{:db/id 1
:block/uuid #uuid "00000000-0000-0000-0000-000000000001"
:block/raw-title "some content"
:logseq.property.node/display-type :code
:block/tags [{:db/ident :logseq.class/Query}]})]
(is (= :code (:type seg))))))
;; ---------------------------------------------------------------------------
;; segments->full-title
;; ---------------------------------------------------------------------------
(deftest segments->full-title-test
(testing "joins segment texts with /"
(is (= "My Page / Parent / Child"
(model/segments->full-title
[{:text "My Page"} {:text "Parent"} {:text "Child"}]))))
(testing "falls back to full-text when text is nil"
(is (= "My Page / Full label"
(model/segments->full-title
[{:text "My Page"} {:text nil :full-text "Full label"}]))))
(testing "skips nils"
(is (= "A / C"
(model/segments->full-title
[{:text "A"} {:text nil :full-text nil} {:text "C"}])))))
;; ---------------------------------------------------------------------------
;; build-breadcrumb-view
;; ---------------------------------------------------------------------------
(defn- make-segs
"Build a vector of minimal segment maps with labels :s0 :s1 ..."
[n]
(vec (for [i (range n)]
{:db/id 1
:block/uuid (random-uuid)
:type (if (zero? i) :page :block)
:text (str "Seg" i)
:full-text (str "Segment " i)
:page? (zero? i)})))
(deftest build-breadcrumb-view-test
(testing "empty segments return all-empty result"
(let [result (model/build-breadcrumb-view [] {:max-visible 4 :nearest-count 2 :show-page? true})]
(is (= [] (:visible-prefix result)))
(is (= [] (:hidden result)))
(is (= [] (:visible-suffix result)))
(is (false? (:overflow? result)))))
(testing "1-segment path: no overflow"
(let [segs (make-segs 1)
result (model/build-breadcrumb-view segs {:max-visible 4 :nearest-count 2 :show-page? true})]
(is (= segs (:visible-prefix result)))
(is (false? (:overflow? result)))))
(testing "2-segment path within budget: no overflow"
(let [segs (make-segs 2)
result (model/build-breadcrumb-view segs {:max-visible 4 :nearest-count 2 :show-page? true})]
(is (false? (:overflow? result)))
(is (= segs (:visible-prefix result)))))
(testing "5-segment path with max-visible 4: overflows"
(let [segs (make-segs 5)
result (model/build-breadcrumb-view segs {:max-visible 4 :nearest-count 2 :show-page? true})]
(is (true? (:overflow? result)))
;; prefix = first 1 (page), suffix = last 2 (nearest parents), hidden = middle 2
(is (= [(nth segs 0)] (:visible-prefix result)))
(is (= [(nth segs 3) (nth segs 4)] (:visible-suffix result)))
(is (= [(nth segs 1) (nth segs 2)] (:hidden result)))))
(testing "20-segment path with default budget: overflows"
(let [segs (make-segs 20)
result (model/build-breadcrumb-view segs {:max-visible 4 :nearest-count 2 :show-page? true})]
(is (true? (:overflow? result)))
(is (= 1 (count (:visible-prefix result))))
(is (= 2 (count (:visible-suffix result))))
(is (= 17 (count (:hidden result))))))
(testing "show-page? false drops the first segment"
(let [segs (make-segs 5)
result (model/build-breadcrumb-view segs {:max-visible 4 :nearest-count 2 :show-page? false})]
;; after dropping page, 4 remain, max-visible 4 => no overflow
(is (false? (:overflow? result)))))
(testing "search-result variant: max-visible 3, nearest-count 1"
(let [segs (make-segs 5)
opts (model/variant-options :search-result)
result (model/build-breadcrumb-view segs opts)]
(is (true? (:overflow? result)))
;; prefix = 1 page, suffix = 1 nearest, hidden = 3 middle
(is (= 1 (count (:visible-prefix result))))
(is (= 1 (count (:visible-suffix result))))
(is (= 3 (count (:hidden result)))))))