From 51f0ce261c14a925b802e314ca29110fa7087b2e Mon Sep 17 00:00:00 2001 From: megayu Date: Fri, 8 May 2026 17:14:13 +0800 Subject: [PATCH] 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 --- deps/db/src/logseq/db.cljs | 1 + src/main/frontend/components/block.cljs | 306 ++++++++++++---- src/main/frontend/components/block.css | 65 ++++ .../components/block/breadcrumb_model.cljs | 290 +++++++++++++++ src/main/frontend/components/cmdk/cmdk.css | 15 + src/main/frontend/components/cmdk/core.cljs | 7 +- .../frontend/components/cmdk/list_item.cljs | 10 +- src/main/frontend/components/header.cljs | 14 +- src/main/frontend/worker/db_core.cljs | 5 +- src/main/logseq/db_worker/server_list.cljs | 11 +- src/main/mobile/search.cljs | 33 +- src/resources/dicts/en.edn | 2 + src/resources/dicts/zh-cn.edn | 2 + .../block/breadcrumb_model_test.cljs | 343 ++++++++++++++++++ 14 files changed, 1007 insertions(+), 97 deletions(-) create mode 100644 src/main/frontend/components/block/breadcrumb_model.cljs create mode 100644 src/test/frontend/components/block/breadcrumb_model_test.cljs diff --git a/deps/db/src/logseq/db.cljs b/deps/db/src/logseq/db.cljs index 7e3758709f..330b13c9e0 100644 --- a/deps/db/src/logseq/db.cljs +++ b/deps/db/src/logseq/db.cljs @@ -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) diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index dd20d2916e..3f2454517f 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -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/ (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/ (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})))) diff --git a/src/main/frontend/components/cmdk/cmdk.css b/src/main/frontend/components/cmdk/cmdk.css index ff09633707..abfa1411f2 100644 --- a/src/main/frontend/components/cmdk/cmdk.css +++ b/src/main/frontend/components/cmdk/cmdk.css @@ -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)))); diff --git a/src/main/frontend/components/cmdk/core.cljs b/src/main/frontend/components/cmdk/core.cljs index 13e0e01c82..c4cb2884a6 100644 --- a/src/main/frontend/components/cmdk/core.cljs +++ b/src/main/frontend/components/cmdk/core.cljs @@ -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)] diff --git a/src/main/frontend/components/cmdk/list_item.cljs b/src/main/frontend/components/cmdk/list_item.cljs index e935742aae..9662916c82 100644 --- a/src/main/frontend/components/cmdk/list_item.cljs +++ b/src/main/frontend/components/cmdk/list_item.cljs @@ -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) diff --git a/src/main/frontend/components/header.cljs b/src/main/frontend/components/header.cljs index ebf22f73c7..3c86d02671 100644 --- a/src/main/frontend/components/header.cljs +++ b/src/main/frontend/components/header.cljs @@ -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 diff --git a/src/main/frontend/worker/db_core.cljs b/src/main/frontend/worker/db_core.cljs index 79aafb5b5f..af77ae4666 100644 --- a/src/main/frontend/worker/db_core.cljs +++ b/src/main/frontend/worker/db_core.cljs @@ -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] diff --git a/src/main/logseq/db_worker/server_list.cljs b/src/main/logseq/db_worker/server_list.cljs index cf469b3df1..8a5e28f143 100644 --- a/src/main/logseq/db_worker/server_list.cljs +++ b/src/main/logseq/db_worker/server_list.cljs @@ -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] diff --git a/src/main/mobile/search.cljs b/src/main/mobile/search.cljs index b26abb7a3f..66c045fb4a 100644 --- a/src/main/mobile/search.cljs +++ b/src/main/mobile/search.cljs @@ -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) diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index dc5d8382f3..fc3e19a9b2 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -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" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index 5cb276c2d7..bf06afc0ff 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -111,6 +111,8 @@ :block.reaction/emoji-required-warning "请先选择一个表情回应。" :block.reaction/unsupported-emoji-warning "不支持该表情回应。" + :breadcrumb/more-ancestors "显示更多上级节点" + :bug-report/desc "你可以帮助我们提交一个错误报告吗?我们会尽快解决。" :bug-report/title "BUG 上报" diff --git a/src/test/frontend/components/block/breadcrumb_model_test.cljs b/src/test/frontend/components/block/breadcrumb_model_test.cljs new file mode 100644 index 0000000000..7ea97f2c99 --- /dev/null +++ b/src/test/frontend/components/block/breadcrumb_model_test.cljs @@ -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)))))))