From 5bc8fafb1b3365ab0ab2ad04acb79f81519ff0fc Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 6 May 2026 20:50:37 +0800 Subject: [PATCH] fix: debounce search results --- src/main/frontend/components/cmdk/core.cljs | 122 ++++++++++++------ src/main/frontend/components/cmdk/state.cljs | 16 ++- src/main/frontend/components/editor.cljs | 21 ++- src/main/frontend/handler/editor.cljs | 2 + src/main/frontend/worker/search.cljs | 60 ++++++--- .../frontend/components/cmdk/state_test.cljs | 22 +++- src/test/frontend/worker/search_test.cljs | 55 ++++++++ 7 files changed, 225 insertions(+), 73 deletions(-) diff --git a/src/main/frontend/components/cmdk/core.cljs b/src/main/frontend/components/cmdk/core.cljs index 78ef7c0583..13e0e01c82 100644 --- a/src/main/frontend/components/cmdk/core.cljs +++ b/src/main/frontend/components/cmdk/core.cljs @@ -197,7 +197,8 @@ group-key (if (= group-key :create) (count group-items) - (count (get-in results [group-key :items]))) + (or (get-in results [group-key :matched-count]) + (count (get-in results [group-key :items])))) (mapv #(assoc % :group group-key :item-index (vswap! index inc)) group-items)]))) (defn state->highlighted-item @@ -383,19 +384,31 @@ (= page-id current-page-uuid)) :source-block block})) +(defn- block-search-result->items + [result] + (if (map? result) + {:blocks (:items result) + :matched-count (or (:matched-count result) + (count (:items result)))} + {:blocks result + :matched-count (count result)})) + ;; The blocks search action uses an existing handler (defmethod load-results :nodes [group state] (let [!input (::input state) !results (::results state) repo (state/get-current-repo) current-page-uuid (page-util/get-current-page-uuid) + expanded? (::expanded? state) opts (cmdk-state/cmdk-block-search-options {:filter-group :nodes :dev? config/dev? - :action (get-action)})] + :action (get-action) + :expanded? expanded?})] (swap! !results assoc-in [group :status] :loading) (swap! !results assoc-in [:current-page :status] :loading) - (p/let [blocks (search/block-search repo @!input opts) + (p/let [search-result (search/block-search repo @!input opts) + {:keys [blocks matched-count]} (block-search-result->items search-result) blocks (remove nil? blocks) items (keep (fn [block] (if (:page? block) @@ -403,8 +416,14 @@ (block-item repo block current-page-uuid @!input))) blocks)] (if (= group :current-page) (let [items-on-current-page (filter :current-page? items)] - (swap! !results update group merge {:status :success :items items-on-current-page})) - (swap! !results update group merge {:status :success :items items}))))) + (swap! !results update group merge {:status :success + :items items-on-current-page + :matched-count (count items-on-current-page) + :has-more? false})) + (swap! !results update group merge {:status :success + :items items + :matched-count matched-count + :has-more? (> matched-count (count items))}))))) (defmethod load-results :codes [group state] (let [!input (::input state) @@ -478,13 +497,16 @@ (let [!results (::results state) !input (::input state) repo (state/get-current-repo) + expanded? (::expanded? state) opts (cmdk-state/cmdk-block-search-options {:filter-group :current-page :dev? config/dev? - :page-uuid (:block/uuid current-page)})] + :page-uuid (:block/uuid current-page) + :expanded? expanded?})] (swap! !results assoc-in [group :status] :loading) (swap! !results assoc-in [:current-page :status] :loading) - (p/let [blocks (search/block-search repo @!input opts) + (p/let [search-result (search/block-search repo @!input opts) + {:keys [blocks matched-count]} (block-search-result->items search-result) blocks (remove nil? blocks) items (map (fn [block] (let [id (if (uuid? (:block/uuid block)) @@ -497,7 +519,10 @@ :result-type (if (:page? block) :page :block) :current-page? true :source-block block})) blocks)] - (swap! !results update :current-page merge {:status :success :items items}))) + (swap! !results update :current-page merge {:status :success + :items items + :matched-count matched-count + :has-more? (> matched-count (count items))}))) (reset! (::filter state) nil))) ;; The default load-results function triggers all the other load-results function @@ -838,14 +863,20 @@ nil)] [:div {:data-item-index item-idx} (if (= group :nodes) - (ui/lazy-visible (fn [] item) {:root scroll-root - :root-margin "500px 0px"}) - item)])) + (ui/lazy-visible (fn [] item) {:root scroll-root + :root-margin "500px 0px"}) + item)])) + +(defn- show-more-results! + [state group] + (swap! (::results state) assoc-in [group :show] :more) + (when (contains? #{:nodes :current-page} group) + (load-results group (assoc state ::expanded? true)))) (rum/defcs result-group < rum/reactive [state' state title group visible-items first-item sidebar?] - (let [{:keys [show items]} (some-> state ::results deref group) + (let [{:keys [show items matched-count has-more?]} (some-> state ::results deref group) focus-source @(::focus-source state) highlighted-item (or @(::highlighted-item state) (when (= :keyboard focus-source) first-item)) @@ -853,9 +884,10 @@ input @(::input state) filter' @(::filter state) can-show-less? (< (get-group-limit group) (count visible-items)) - can-show-more? (< (count visible-items) (count items)) + can-show-more? (or has-more? + (< (count visible-items) (count items))) show-less #(swap! (::results state) assoc-in [group :show] :less) - show-more #(swap! (::results state) assoc-in [group :show] :more)] + show-more #(show-more-results! state group)] [:div {:class (if (= group :create) "border-b border-gray-06 last:border-b-0" "border-b border-gray-06 pb-1 last:border-b-0")} @@ -864,15 +896,17 @@ [:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none" :on-click (fn [_e] ;; change :less to :more or :more to :less - (swap! (::results state) update-in [group :show] {:more :less - :less :more}))} + (if (= show :more) + (show-less) + (show-more)))} title] (when (not= group :create) - [:div {:class "pl-1.5 text-gray-12 rounded-full" - :style {:font-size "0.7rem"}} - (if (<= 100 (count items)) - "99+" - (count items))]) + (let [display-count (or matched-count (count items))] + [:div {:class "pl-1.5 text-gray-12 rounded-full" + :style {:font-size "0.7rem"}} + (if (<= 99 display-count) + "99+" + display-count)])) [:div {:class "flex-1"}] @@ -946,9 +980,15 @@ (reset! (::pending-scroll-item-idx state) nil) (reset! (::highlighted-item state) nil))))) +(defn- refresh-results! + [state] + (persist-cmdk-query-state! state) + (load-results :default state)) + (defn handle-input-change - ([state e] (handle-input-change state e (.. e -target -value))) - ([state e input] + ([state e] (handle-input-change state e (.. e -target -value) true)) + ([state e input] (handle-input-change state e input true)) + ([state e input refresh?] (let [composing? (util/native-event-is-composing? e) e-type (gobj/getValueByKeys e "type") composing-end? (= e-type "compositionend") @@ -964,9 +1004,8 @@ (when container (set! (.-scrollTop container) 0)) ;; retrieve the load-results function and update all the results - (when (or (not composing?) composing-end?) - (persist-cmdk-query-state! state) - (load-results :default state))))) + (when (and refresh? (or (not composing?) composing-end?)) + (refresh-results! state))))) (defn- open-current-item-link "Opens a link for the current item if a page or block. For pages, opens the @@ -1013,7 +1052,7 @@ (swap! (::results state) assoc-in [highlighted-group :show] :less))) show-more (fn [] (when highlighted-group - (swap! (::results state) assoc-in [highlighted-group :show] :more))) + (show-more-results! state highlighted-group))) input @(::input state) as-keydown? (or (= keyname "ArrowDown") (and ctrl? (= keyname "n"))) as-keyup? (or (= keyname "ArrowUp") (and ctrl? (= keyname "p")))] @@ -1098,17 +1137,11 @@ (let [highlighted-item @(::highlighted-item state) input @(::input state) input-ref (::input-ref state) - debounced-on-change (hooks/use-callback - (gfun/debounce - (fn [e] - (let [new-value (.-value (.-target e))] - (handle-input-change state e) - (when-let [on-change (:on-input-change opts)] - (on-change new-value)))) - 200) - []) - debounced-composition-end (hooks/use-callback - (gfun/debounce (fn [e] (handle-input-change state e)) 100) + debounced-refresh-results (hooks/use-callback + (gfun/debounce + (fn [] + (refresh-results! state)) + 150) [])] (hooks/use-effect! (fn [] (reset! (::all-items-cache state) (vec all-items)) @@ -1142,11 +1175,20 @@ :autoCapitalize "off" :placeholder (input-placeholder) :ref #(when-not @input-ref (reset! input-ref %)) - :on-change debounced-on-change + :on-change (fn [e] + (let [new-value (.-value (.-target e)) + composing? (util/native-event-is-composing? e)] + (handle-input-change state e new-value false) + (when-not composing? + (debounced-refresh-results)) + (when-let [on-change (:on-input-change opts)] + (on-change new-value)))) :on-blur (fn [_e] (when-let [on-blur (:on-input-blur opts)] (on-blur input))) - :on-composition-end debounced-composition-end + :on-composition-end (fn [e] + (handle-input-change state e (.. e -target -value) false) + (debounced-refresh-results)) :default-value input}]])) (defn- tip-with-shortcut diff --git a/src/main/frontend/components/cmdk/state.cljs b/src/main/frontend/components/cmdk/state.cljs index 79e9bdb527..cc68e816ae 100644 --- a/src/main/frontend/components/cmdk/state.cljs +++ b/src/main/frontend/components/cmdk/state.cljs @@ -70,21 +70,27 @@ (save-last-cmdk-search! repo input (:group filter-state)))) (defn cmdk-block-search-options - [{:keys [filter-group dev? action page-uuid]}] - (let [nodes-base {:limit 100 + [{:keys [filter-group dev? action page-uuid expanded?]}] + (let [nodes-limit (if expanded? 100 10) + nodes-base {:limit nodes-limit + :search-limit 100 :dev? dev? :built-in? true - :enable-snippet? true}] + :enable-snippet? true + :include-matched-count? true}] (case filter-group :code (assoc nodes-base + :limit 20 ;; larger limit for code search since most of the results will be filtered out :search-limit 300 :code-only? true) :current-page - (cond-> {:limit 100 - :enable-snippet? true} + (cond-> {:limit nodes-limit + :search-limit 100 + :enable-snippet? true + :include-matched-count? true} page-uuid (assoc :page (str page-uuid))) diff --git a/src/main/frontend/components/editor.cljs b/src/main/frontend/components/editor.cljs index 5104eb75be..1d24dc56fc 100644 --- a/src/main/frontend/components/editor.cljs +++ b/src/main/frontend/components/editor.cljs @@ -307,12 +307,23 @@ ;; TODO: use rum/use-effect instead (rum/defcs block-search-auto-complete < rum/reactive {:init (fn [state] - (let [result (atom nil)] - (search-blocks! state result) - (assoc state ::result result))) + (let [result (atom nil) + [debounced-search stop-search!] (util/cancelable-debounce search-blocks! 150)] + (debounced-search state result) + (assoc state + ::result result + ::debounced-search debounced-search + ::stop-search! stop-search!))) :did-update (fn [state] - (search-blocks! state (::result state)) - state)} + (let [[_edit-block _ _ q] (:rum/args state)] + (if (string/blank? q) + (reset! (::result state) nil) + ((::debounced-search state) state (::result state)))) + state) + :will-unmount (fn [state] + (when-let [stop-search! (::stop-search! state)] + (stop-search!)) + state)} [state _edit-block input id q format selected-text] (let [result (->> (rum/react (get state ::result)) (remove (fn [b] (nil? (:block/uuid b))))) diff --git a/src/main/frontend/handler/editor.cljs b/src/main/frontend/handler/editor.cljs index 840b24a2fe..7e9d1bf26f 100644 --- a/src/main/frontend/handler/editor.cljs +++ b/src/main/frontend/handler/editor.cljs @@ -1542,6 +1542,8 @@ [q & [{:keys [nlp-pages? page-only?]}]] (p/let [block (state/get-edit-block) result (search/block-search (state/get-current-repo) q {:built-in? false + :limit 20 + :search-limit 100 :enable-snippet? false :page-only? page-only?}) matched (remove (fn [b] (= (:block/uuid b) (:block/uuid block))) result) diff --git a/src/main/frontend/worker/search.cljs b/src/main/frontend/worker/search.cljs index b3cb6f5a7c..37d8eeeecf 100644 --- a/src/main/frontend/worker/search.cljs +++ b/src/main/frontend/worker/search.cljs @@ -584,21 +584,42 @@ DROP TRIGGER IF EXISTS blocks_au; (when (include-search-block? conn block code-class option) (let [display-title (if (:enable-snippet? option) (ensure-highlighted-snippet snippet title q) - (or snippet title))] - {:db/id (:db/id block) - :block/uuid (:block/uuid block) - :block/title display-title - :block.temp/original-title (:block/title block) - :block/page (or - (:block/uuid (:block/page block)) - (when (and page (common-util/uuid-string? page)) - (uuid page))) - :block/parent (:db/id (:block/parent block)) - :block/tags (seq (map :db/id (:block/tags block))) - :logseq.property/icon (:logseq.property/icon block) - :page? (ldb/page? block) - :alias (some-> (first (:block/_alias block)) - (select-keys [:block/uuid :block/title]))}))))) + (or snippet title)) + block-page (or + (:block/uuid (:block/page block)) + (when (and page (common-util/uuid-string? page)) + (uuid page))) + parent-id (:db/id (:block/parent block)) + tag-ids (seq (map :db/id (:block/tags block))) + icon (:logseq.property/icon block) + alias (some-> (first (:block/_alias block)) + (select-keys [:block/uuid :block/title]))] + (cond-> {:db/id (:db/id block) + :block/uuid (:block/uuid block) + :block/title display-title + :block.temp/original-title (:block/title block) + :page? (ldb/page? block)} + block-page + (assoc :block/page block-page) + + parent-id + (assoc :block/parent parent-id) + + tag-ids + (assoc :block/tags tag-ids) + + icon + (assoc :logseq.property/icon icon) + + alias + (assoc :alias alias))))))) + +(defn- search-result-visible? + [conn code-class option {:keys [id] :as result}] + (let [block-id (uuid id)] + (when-let [block (or (get result search-result-block-key) + (d/entity @conn [:block/uuid block-id]))] + (include-search-block? conn block code-class option)))) (defn search-blocks "Options: @@ -609,7 +630,7 @@ DROP TRIGGER IF EXISTS blocks_au; * :dev? - Allow all nodes to be seen for development. Defaults to false * :code-only? - Whether to return only code blocks. Defaults to false * :built-in? - Whether to return public built-in nodes for db graphs. Defaults to false" - [conn search-db q {:keys [limit search-limit page enable-snippet? page-only? code-only?] + [conn search-db q {:keys [limit search-limit page enable-snippet? page-only? code-only? include-matched-count?] :as option :or {enable-snippet? true}}] (when-not (string/blank? q) @@ -639,10 +660,15 @@ DROP TRIGGER IF EXISTS blocks_au; combined-result (combine-results @conn (concat fuzzy-result matched-result non-match-result)) code-class (when code-only? (d/entity @conn :logseq.class/Code-block)) + matched-count (when include-matched-count? + (count (filter #(search-result-visible? conn code-class option %) combined-result))) result (->> combined-result (common-util/distinct-by :id) (keep #(search-result->block-result conn q code-class option %)))] - (take limit result)))) + (if include-matched-count? + {:items (take limit result) + :matched-count matched-count} + (take limit result))))) (defn truncate-table! [db] diff --git a/src/test/frontend/components/cmdk/state_test.cljs b/src/test/frontend/components/cmdk/state_test.cljs index 6375840387..cd30e3fff7 100644 --- a/src/test/frontend/components/cmdk/state_test.cljs +++ b/src/test/frontend/components/cmdk/state_test.cljs @@ -87,33 +87,43 @@ (deftest cmdk-block-search-options-default-and-nodes (testing "default and nodes options keep snippet enabled and include expected base params" - (is (= {:limit 100 :dev? false :built-in? true :enable-snippet? true} + (is (= {:limit 10 :search-limit 100 :dev? false :built-in? true :enable-snippet? true + :include-matched-count? true} (state/cmdk-block-search-options {:dev? false}))) - (is (= {:limit 100 :dev? true :built-in? true :enable-snippet? true} - (state/cmdk-block-search-options {:filter-group :nodes :dev? true}))))) + (is (= {:limit 10 :search-limit 100 :dev? true :built-in? true :enable-snippet? true + :include-matched-count? true} + (state/cmdk-block-search-options {:filter-group :nodes :dev? true}))) + (is (= {:limit 100 :search-limit 100 :dev? true :built-in? true :enable-snippet? true + :include-matched-count? true} + (state/cmdk-block-search-options {:filter-group :nodes :dev? true :expanded? true}))))) (deftest cmdk-block-search-options-code (testing "code filter options include larger search limit and code-only flag" - (is (= {:limit 100 + (is (= {:limit 20 :search-limit 300 :dev? true :built-in? true :enable-snippet? true + :include-matched-count? true :code-only? true} (state/cmdk-block-search-options {:filter-group :code :dev? true}))))) (deftest cmdk-block-search-options-current-page-and-move-blocks (testing "current-page adds page and move-blocks adds page-only flag" - (is (= {:limit 100 + (is (= {:limit 10 + :search-limit 100 :enable-snippet? true + :include-matched-count? true :page "00000000-0000-0000-0000-000000000111"} (state/cmdk-block-search-options {:filter-group :current-page :dev? false :page-uuid #uuid "00000000-0000-0000-0000-000000000111"}))) - (is (= {:limit 100 + (is (= {:limit 10 + :search-limit 100 :dev? true :built-in? true :enable-snippet? true + :include-matched-count? true :page-only? true} (state/cmdk-block-search-options {:filter-group :nodes :dev? true diff --git a/src/test/frontend/worker/search_test.cljs b/src/test/frontend/worker/search_test.cljs index b2f738e778..1cd05eedc5 100644 --- a/src/test/frontend/worker/search_test.cljs +++ b/src/test/frontend/worker/search_test.cljs @@ -321,6 +321,61 @@ "logseq" {:limit 10 :enable-snippet? false})))))))) +(deftest search-blocks-can-return-matched-count + (testing "cmd-k can show total matched nodes while returning the first page only" + (let [rows (mapv (fn [n] + {:id (test-uuid-string n) + :page (test-uuid-string n) + :title (str "logseq result " n) + :keyword-score 1}) + (range 1 101)) + blocks (into {} + (map (fn [{:keys [id title]}] + [id {:db/id id + :block/uuid (uuid id) + :block/title title}]) + rows))] + (with-redefs [search/combine-results (fn [_db results] + (doall results) + rows) + d/entity (fn [_db [_attr id]] + (get blocks (str id))) + ldb/page? (constantly false) + ldb/built-in? (constantly false)] + (let [result (search/search-blocks (atom :large-db) + (checking-db) + "logseq" + {:limit 10 + :enable-snippet? false + :include-matched-count? true})] + (is (= 10 (count (:items result)))) + (is (= 100 (:matched-count result)))))))) + +(deftest search-result-omits-empty-optional-fields + (testing "search responses avoid serializing nil optional fields for every row" + (let [block-id #uuid "00000000-0000-0000-0000-000000000123" + block {:db/id 1 + :block/uuid block-id + :block/title "logseq result"}] + (with-redefs [d/entity (fn [_db [_attr id]] + (when (= id block-id) + block)) + ldb/page? (constantly false) + ldb/built-in? (constantly false)] + (let [result (#'search/search-result->block-result + (atom :db) + "logseq" + nil + {:enable-snippet? false} + {:id (str block-id) + :page (str block-id) + :title "logseq result"})] + (is (= "logseq result" (:block/title result))) + (is (not (contains? result :block/parent))) + (is (not (contains? result :block/tags))) + (is (not (contains? result :logseq.property/icon))) + (is (not (contains? result :alias)))))))) + (deftest upsert-blocks-batches-rows-into-single-sql-statement (let [calls (atom []) tx #js {:exec (fn [opts]