mirror of
https://github.com/logseq/logseq.git
synced 2026-05-17 17:32:35 +00:00
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:
1
deps/db/src/logseq/db.cljs
vendored
1
deps/db/src/logseq/db.cljs
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
290
src/main/frontend/components/block/breadcrumb_model.cljs
Normal file
290
src/main/frontend/components/block/breadcrumb_model.cljs
Normal 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}))))
|
||||
@@ -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))));
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
:block.reaction/emoji-required-warning "请先选择一个表情回应。"
|
||||
:block.reaction/unsupported-emoji-warning "不支持该表情回应。"
|
||||
|
||||
:breadcrumb/more-ancestors "显示更多上级节点"
|
||||
|
||||
:bug-report/desc "你可以帮助我们提交一个错误报告吗?我们会尽快解决。"
|
||||
:bug-report/title "BUG 上报"
|
||||
|
||||
|
||||
343
src/test/frontend/components/block/breadcrumb_model_test.cljs
Normal file
343
src/test/frontend/components/block/breadcrumb_model_test.cljs
Normal 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)))))))
|
||||
Reference in New Issue
Block a user