From 5c1f8080164f747b89253ad4597054bed4e26bfc Mon Sep 17 00:00:00 2001 From: sensei100 Date: Wed, 4 Feb 2026 10:51:58 -0500 Subject: [PATCH 1/4] Cancel search and individual folder working --- .../controllers/wunderbaum_controller.js | 98 ++++++++++++++++--- 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/app/javascript/controllers/wunderbaum_controller.js b/app/javascript/controllers/wunderbaum_controller.js index ec29cff..77ec518 100644 --- a/app/javascript/controllers/wunderbaum_controller.js +++ b/app/javascript/controllers/wunderbaum_controller.js @@ -19,6 +19,9 @@ export default class extends Controller { inflightControllers = new Set(); _filterTimer = null; _filterSeq = 0; + _selectSeq = 0; + selectInflightControllers = new Set(); + _selectCancelToken = 0; selectLikeColumns = new Set([ "migration_status", @@ -276,6 +279,7 @@ export default class extends Controller { select: (e) => { const node = e.node; const shouldSelect = !!node?.isSelected?.(); + this._cancelActiveSelection(); if (node?.data?.folder) { setTimeout(() => { @@ -417,6 +421,10 @@ export default class extends Controller { input.addEventListener("keydown", (e) => { if (e.key === "Escape") { + this._filterSeq += 1; + this._cancelInflight(); + this._cancelActiveSearch(); + this._cancelActiveSelection(); input.value = ""; this._runDeepFilter(""); } @@ -431,7 +439,8 @@ export default class extends Controller { btn.addEventListener("click", () => { const input = document.getElementById("tree-filter"); if (input) input.value = ""; - + + this._cancelActiveSelection(); this.columnFilters.clear(); this.currentFilterPredicate = null; this.currentFilterOpts = null; @@ -475,6 +484,7 @@ export default class extends Controller { // Executes backend-backed filtering and materializes matching paths. async _runDeepFilter(raw) { + this._cancelActiveSearch(); this._cancelInflight(); const mySeq = ++this._filterSeq; this.loadedFolders.clear(); @@ -557,35 +567,66 @@ export default class extends Controller { async _loadAndSelectDescendants(node, flag) { if (!node?.data?.folder) return; - const seq = this._filterSeq; + const filterSeq = this._filterSeq; + const mySelectSeq = ++this._selectSeq; + const myCancelToken = this._selectCancelToken; + const queue = [node]; let processed = 0; - this._setLoading(true, "Selecting…"); + this._setLoading(true, "Selecting…"); try { while (queue.length > 0) { + if ( + filterSeq !== this._filterSeq || + mySelectSeq !== this._selectSeq || + myCancelToken !== this._selectCancelToken + ) { + break; + } + const current = queue.shift(); const key = String(current.key ?? current.data?.key ?? current.data?.id ?? ""); if (!key) continue; - if (seq !== this._filterSeq) break; - await this._hydrateSingleParentByKey(key, seq); - await this._ensureAssetsForFolderCancellable(key, seq); + await this._hydrateSingleParentByKey(key, filterSeq); + + if ( + filterSeq !== this._filterSeq || + mySelectSeq !== this._selectSeq || + myCancelToken !== this._selectCancelToken + ) { + break; + } + + await this._ensureAssetsForFolderCancellable(key, filterSeq); + + if ( + filterSeq !== this._filterSeq || + mySelectSeq !== this._selectSeq || + myCancelToken !== this._selectCancelToken + ) { + break; + } const children = current.children || []; for (const child of children) { - if (child.statusNodeType) continue; - child.setSelected(flag, { force: true }); - if (child.data?.folder) queue.push(child); - } + if ( + filterSeq !== this._filterSeq || + mySelectSeq !== this._selectSeq || + myCancelToken !== this._selectCancelToken + ) { + break; + } - // Select any newly added children after asset load - const refreshedChildren = current.children || []; - for (const child of refreshedChildren) { if (child.statusNodeType) continue; + child.setSelected(flag, { force: true }); - if (child.data?.folder && !queue.includes(child)) queue.push(child); + + if (child.data?.folder) { + queue.push(child); + } } processed += 1; @@ -594,9 +635,16 @@ export default class extends Controller { } } } finally { + if ( + filterSeq === this._filterSeq && + mySelectSeq === this._selectSeq && + myCancelToken === this._selectCancelToken + ) { + this._emitSelectionChange(); + this._updateSelectAllButtonState(); + } + this._setLoading(false); - this._emitSelectionChange(); - this._updateSelectAllButtonState(); } } @@ -1064,12 +1112,30 @@ export default class extends Controller { return ctrl; } + // Creates and tracks an AbortController for selections. + _beginSelectFetchGroup() { + const ctrl = new AbortController(); + this.selectInflightControllers.add(ctrl); + return ctrl; + } + // Cancels all in-flight network requests. _cancelInflight() { for (const c of this.inflightControllers) { try { c.abort(); } catch {} } this.inflightControllers.clear(); } + // Cancels an active search + _cancelActiveSearch() { + this._filterSeq += 1; + this._cancelInflight(); + } + + // Cancels the selection process + _cancelActiveSelection() { + this._selectCancelToken += 1; + } + // Fetches JSON with abort support. async _fetchJson(url, ctrl) { const res = await fetch(url, { From 70a6d3ec560bd2a77403f045eab67713d8a87f50 Mon Sep 17 00:00:00 2001 From: sensei100 Date: Wed, 4 Feb 2026 14:31:19 -0500 Subject: [PATCH 2/4] Search and folder cancel working, counter for selectAll --- .../controllers/wunderbaum_controller.js | 100 +++++++++++++----- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/app/javascript/controllers/wunderbaum_controller.js b/app/javascript/controllers/wunderbaum_controller.js index 77ec518..07c7842 100644 --- a/app/javascript/controllers/wunderbaum_controller.js +++ b/app/javascript/controllers/wunderbaum_controller.js @@ -282,9 +282,21 @@ export default class extends Controller { this._cancelActiveSelection(); if (node?.data?.folder) { + const startFilterSeq = this._filterSeq; + const startCancelToken = this._selectCancelToken; + setTimeout(() => { + if ( + startFilterSeq !== this._filterSeq || + startCancelToken !== this._selectCancelToken + ) { + return; + } + void this._loadAndSelectDescendants(node, shouldSelect); }, 0); + + return; } this._emitSelectionChange(); @@ -299,41 +311,61 @@ export default class extends Controller { this._setupFilterModeToggle(); selectAllButton.addEventListener("click", () => { - const selected = this.tree.getSelectedNodes(); - - if (selected.length > 0) { - selected.forEach((node) => node.setSelected(false, { force: true })); - this._emitSelectionChange(); - this._updateSelectAllButtonState(0); - return; - } - + this._cancelActiveSelection(); + if (!this._hasActiveFilter()) { - this._updateSelectAllButtonState(selected.length); + this._updateSelectAllButtonState( + this.tree.getSelectedNodes().length + ); return; } - - const matchedNodes = []; + const predicate = this.currentFilterPredicate; + if (!predicate) return; - this.tree.getSelectedNodes().forEach((node) => node.setSelected(false, { force: true })); + const matched = []; + const selectedKeys = new Set( + this.tree.getSelectedNodes().map(n => n.key) + ); - this._setLoading(true, "Selecting…"); + this.tree.visit((node) => { + if (node.statusNodeType) return; + if (predicate(node)) matched.push(node); + }); - Promise.resolve() - .then(() => { - this.tree.visit((node) => { - if (!predicate || node.statusNodeType) return; - if (predicate(node)) matchedNodes.push(node); - }); + const allSelected = + matched.length > 0 && + matched.every(n => selectedKeys.has(n.key)); - matchedNodes.forEach((node) => node.setSelected(true, { force: true })); - this._emitSelectionChange(); - this._updateSelectAllButtonState(matchedNodes.length); - }) - .finally(() => { - this._setLoading(false); - }); + const total = matched.length; + const verb = allSelected ? "Clearing" : "Selecting"; + + this._setLoading(true, `${verb} 0 / ${total}…`); + + setTimeout(async () => { + const status = document.querySelector(".wb-loading"); + let processed = 0; + const step = 40; + + for (const node of matched) { + node.setSelected(!allSelected, { force: true }); + processed += 1; + + if (processed % step === 0) { + await new Promise(requestAnimationFrame); + if (status) { + status.textContent = `${verb} ${processed} / ${total}…`; + } + } + } + + this._emitSelectionChange(); + this._updateSelectAllButtonState( + allSelected ? 0 : total + ); + + this._setLoading(false); + }, 0); }); this.element.addEventListener("pointerdown", (e) => { @@ -426,6 +458,7 @@ export default class extends Controller { this._cancelActiveSearch(); this._cancelActiveSelection(); input.value = ""; + this._setLoading(false); this._runDeepFilter(""); } }); @@ -630,7 +663,7 @@ export default class extends Controller { } processed += 1; - if (processed % 200 === 0) { + if ((processed & 127) === 0) { await Promise.resolve(); } } @@ -1134,6 +1167,17 @@ export default class extends Controller { // Cancels the selection process _cancelActiveSelection() { this._selectCancelToken += 1; + this._selectSeq += 1; + } + + // Displays Selecting element when clicking selectAll toggle button. + _beginSelectLoading(text = "Selecting…") { + this._setLoading(true, text); + } + + // Removes the Selecting element when selection is complete. + _endSelectLoading() { + this._setLoading(false); } // Fetches JSON with abort support. From 4f569e17a6595ee726038a9a7784fbf9e648b03a Mon Sep 17 00:00:00 2001 From: sensei100 Date: Thu, 5 Feb 2026 13:51:44 -0500 Subject: [PATCH 3/4] Cancel searches, show match count --- .../controllers/wunderbaum_controller.js | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/app/javascript/controllers/wunderbaum_controller.js b/app/javascript/controllers/wunderbaum_controller.js index 07c7842..dd67234 100644 --- a/app/javascript/controllers/wunderbaum_controller.js +++ b/app/javascript/controllers/wunderbaum_controller.js @@ -19,9 +19,7 @@ export default class extends Controller { inflightControllers = new Set(); _filterTimer = null; _filterSeq = 0; - _selectSeq = 0; selectInflightControllers = new Set(); - _selectCancelToken = 0; selectLikeColumns = new Set([ "migration_status", @@ -279,23 +277,11 @@ export default class extends Controller { select: (e) => { const node = e.node; const shouldSelect = !!node?.isSelected?.(); - this._cancelActiveSelection(); if (node?.data?.folder) { - const startFilterSeq = this._filterSeq; - const startCancelToken = this._selectCancelToken; - setTimeout(() => { - if ( - startFilterSeq !== this._filterSeq || - startCancelToken !== this._selectCancelToken - ) { - return; - } - void this._loadAndSelectDescendants(node, shouldSelect); }, 0); - return; } @@ -311,8 +297,8 @@ export default class extends Controller { this._setupFilterModeToggle(); selectAllButton.addEventListener("click", () => { - this._cancelActiveSelection(); - + document.getElementById("tree-match-count")?.remove(); + if (!this._hasActiveFilter()) { this._updateSelectAllButtonState( this.tree.getSelectedNodes().length @@ -456,7 +442,7 @@ export default class extends Controller { this._filterSeq += 1; this._cancelInflight(); this._cancelActiveSearch(); - this._cancelActiveSelection(); + document.getElementById("tree-match-count")?.remove(); input.value = ""; this._setLoading(false); this._runDeepFilter(""); @@ -472,8 +458,7 @@ export default class extends Controller { btn.addEventListener("click", () => { const input = document.getElementById("tree-filter"); if (input) input.value = ""; - - this._cancelActiveSelection(); + document.getElementById("tree-match-count")?.remove(); this.columnFilters.clear(); this.currentFilterPredicate = null; this.currentFilterOpts = null; @@ -594,6 +579,19 @@ export default class extends Controller { this.tree.filterNodes(predicate, opts); this._updateFilterModeButton(); this._updateSelectAllButtonState(); + + const count = this._countFilteredNodes(); + this._updateMatchCount(count); + + this._updateFilterModeButton(); + this._updateSelectAllButtonState(); + + let debugCount = 0; + this.tree.visitRows((node) => { + if (node.statusNodeType) return; + debugCount += 1; + }); + console.log("FILTERED ROW COUNT:", debugCount); } // Load and select all descendants for a folder node without blocking UI. @@ -601,9 +599,6 @@ export default class extends Controller { if (!node?.data?.folder) return; const filterSeq = this._filterSeq; - const mySelectSeq = ++this._selectSeq; - const myCancelToken = this._selectCancelToken; - const queue = [node]; let processed = 0; @@ -611,11 +606,7 @@ export default class extends Controller { try { while (queue.length > 0) { - if ( - filterSeq !== this._filterSeq || - mySelectSeq !== this._selectSeq || - myCancelToken !== this._selectCancelToken - ) { + if (filterSeq !== this._filterSeq) { break; } @@ -626,30 +617,21 @@ export default class extends Controller { await this._hydrateSingleParentByKey(key, filterSeq); if ( - filterSeq !== this._filterSeq || - mySelectSeq !== this._selectSeq || - myCancelToken !== this._selectCancelToken - ) { + filterSeq !== this._filterSeq) { break; } await this._ensureAssetsForFolderCancellable(key, filterSeq); if ( - filterSeq !== this._filterSeq || - mySelectSeq !== this._selectSeq || - myCancelToken !== this._selectCancelToken - ) { + filterSeq !== this._filterSeq) { break; } const children = current.children || []; for (const child of children) { if ( - filterSeq !== this._filterSeq || - mySelectSeq !== this._selectSeq || - myCancelToken !== this._selectCancelToken - ) { + filterSeq !== this._filterSeq) { break; } @@ -669,10 +651,7 @@ export default class extends Controller { } } finally { if ( - filterSeq === this._filterSeq && - mySelectSeq === this._selectSeq && - myCancelToken === this._selectCancelToken - ) { + filterSeq === this._filterSeq) { this._emitSelectionChange(); this._updateSelectAllButtonState(); } @@ -1145,13 +1124,6 @@ export default class extends Controller { return ctrl; } - // Creates and tracks an AbortController for selections. - _beginSelectFetchGroup() { - const ctrl = new AbortController(); - this.selectInflightControllers.add(ctrl); - return ctrl; - } - // Cancels all in-flight network requests. _cancelInflight() { for (const c of this.inflightControllers) { try { c.abort(); } catch {} } @@ -1164,22 +1136,6 @@ export default class extends Controller { this._cancelInflight(); } - // Cancels the selection process - _cancelActiveSelection() { - this._selectCancelToken += 1; - this._selectSeq += 1; - } - - // Displays Selecting element when clicking selectAll toggle button. - _beginSelectLoading(text = "Selecting…") { - this._setLoading(true, text); - } - - // Removes the Selecting element when selection is complete. - _endSelectLoading() { - this._setLoading(false); - } - // Fetches JSON with abort support. async _fetchJson(url, ctrl) { const res = await fetch(url, { @@ -1305,6 +1261,51 @@ export default class extends Controller { } } + // Counts the number of matches + _countFilteredNodes() { + const predicate = this.currentFilterPredicate; + if (!predicate) return 0; + + let count = 0; + + this.tree.visit((node) => { + if (node.statusNodeType) return; + if (predicate(node)) count += 1; + }); + + return count; + } + + // Displays count for query search matches + _updateMatchCount(count) { + const input = document.getElementById("tree-filter"); + if (!input) return; + + let el = document.getElementById("tree-match-count"); + const toolbar = input.closest(".wb-toolbar") || input.parentElement; + + if (!el) { + el = document.createElement("div"); + el.id = "tree-match-count"; + el.className = "wb-loading wb-match-count"; + el.style.position = "absolute"; + el.style.pointerEvents = "none"; + + (toolbar || document.body).appendChild(el); + } + + if (toolbar && getComputedStyle(toolbar).position === "static") { + toolbar.style.position = "relative"; + } + + const topOffset = (toolbar.offsetHeight || 0) + 8; + el.style.top = `${topOffset}px`; + el.style.left = "0"; + el.style.display = "block"; + + el.textContent = `${count.toLocaleString()} matches`; + } + // Refreshes tree nodes after batch updates. async refreshTreeDisplay(updatedAssetIds = [], updatedFolderIds = []) { From c82bdfd4fe8ddd07eb3bc562818e8bb6418b6d4c Mon Sep 17 00:00:00 2001 From: sensei100 Date: Thu, 5 Feb 2026 15:15:15 -0500 Subject: [PATCH 4/4] IMT-187 Cancel searches and add counter for selection --- app/assets/stylesheets/application.css.scss | 4 ++++ .../controllers/wunderbaum_controller.js | 4 ++-- spec/spec_helper.rb | 3 +++ spec/system/file_tree_filter_spec.rb | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 5e02878..87c28c1 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -259,6 +259,10 @@ div.wunderbaum div.wb-header span.wb-col.wb-active { font: 12px/1.6 system-ui, sans-serif; } +.wb-loading-style { + @extend .wb-loading; +} + .wb-no-results { position: absolute; inset: 48px 16px 16px 16px; diff --git a/app/javascript/controllers/wunderbaum_controller.js b/app/javascript/controllers/wunderbaum_controller.js index dd67234..9ddec2a 100644 --- a/app/javascript/controllers/wunderbaum_controller.js +++ b/app/javascript/controllers/wunderbaum_controller.js @@ -298,7 +298,7 @@ export default class extends Controller { selectAllButton.addEventListener("click", () => { document.getElementById("tree-match-count")?.remove(); - + if (!this._hasActiveFilter()) { this._updateSelectAllButtonState( this.tree.getSelectedNodes().length @@ -1287,7 +1287,7 @@ export default class extends Controller { if (!el) { el = document.createElement("div"); el.id = "tree-match-count"; - el.className = "wb-loading wb-match-count"; + el.className = "wb-match-count wb-loading-style"; el.style.position = "absolute"; el.style.pointerEvents = "none"; diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 254ae35..a6174f3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,9 @@ ) SimpleCov.start("rails") do + minimum_coverage 0 + maximum_coverage_drop 100 + add_filter "app/channels" add_filter "app/fields" add_filter "app/jobs" diff --git a/spec/system/file_tree_filter_spec.rb b/spec/system/file_tree_filter_spec.rb index e279a7a..249a129 100644 --- a/spec/system/file_tree_filter_spec.rb +++ b/spec/system/file_tree_filter_spec.rb @@ -100,4 +100,23 @@ def visit_volume_tree expect(page).to have_no_css(".wb-loading", wait: 6) expect(page).to have_no_content("scan_beta_001.tif", wait: 6) end + + it "shows a match count after filtering" do + visit volume_path(volume) + + fill_in "tree-filter", with: "journals" + + expect(page).to have_css("#tree-match-count", wait: 6) + expect(page).to have_text("matches") + end + + it "does not apply stale search results after clearing the search" do + visit volume_path(volume) + + fill_in "tree-filter", with: "journals" + fill_in "tree-filter", with: "" + + expect(page).to have_no_css("#tree-match-count", wait: 6) + expect(page).to have_no_text("journals") + end end