From 0fea0a6a701603264308cfa23db092e9770ceef3 Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Mon, 18 May 2026 22:20:21 +0200 Subject: [PATCH] fix(sidebar): keep archive collapsed during search (#165) --- .../sidebar/test_archive_search_expansion.py | 59 +++++++++++++++++++ web/sidebar/tree.js | 53 +++++++++++++---- web/sidebar/tree_expansion.js | 17 ++++++ 3 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 tests/sidebar/test_archive_search_expansion.py create mode 100644 web/sidebar/tree_expansion.js diff --git a/tests/sidebar/test_archive_search_expansion.py b/tests/sidebar/test_archive_search_expansion.py new file mode 100644 index 0000000..590576b --- /dev/null +++ b/tests/sidebar/test_archive_search_expansion.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import subprocess +import textwrap +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def run_node_scenario(source: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["node", "--input-type=module"], + input=source, + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + +def test_archive_folder_is_not_forced_open_during_search() -> None: + script = textwrap.dedent( + """ + import assert from "node:assert/strict"; + import { resolveFolderExpanded } from "./web/sidebar/tree_expansion.js"; + + assert.equal( + resolveFolderExpanded({ + forceExpanded: true, + iconKind: "archive", + startExpanded: false, + }), + false, + ); + + assert.equal( + resolveFolderExpanded({ + forceExpanded: true, + iconKind: "folder", + startExpanded: false, + }), + true, + ); + + assert.equal( + resolveFolderExpanded({ + forceExpanded: true, + iconKind: "archive", + isPinned: true, + startExpanded: false, + }), + true, + ); + """ + ) + + result = run_node_scenario(script) + assert result.returncode == 0, result.stderr diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index f881be5..dd0465f 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -106,6 +106,7 @@ import { listAutosaves, revealPresetFolder, } from "./snapshot.js"; +import { resolveFolderExpanded } from "./tree_expansion.js"; // ============================================================================= // Built-in section IDs. These are also pathState key prefixes — every @@ -179,6 +180,7 @@ const SECTIONS = []; * @property {string} path Segment-relative; engine * prefixes with section.id. * @property {boolean} [startExpanded=false] + * @property {boolean} [forceExpandedWhenFiltered=true] * @property {(e: MouseEvent) => void} [onContextMenu] * @property {(sub: SectionCtx) => void} build Populate the sub-folder. * @@ -389,13 +391,25 @@ function renderTree({ treeEl, query }) { // folders only need to provide segment-relative paths. function makeSectionCtx(parentEl, prefix, isFiltered, validPaths) { return { - folder({ name, count, iconKind, path, startExpanded = false, onContextMenu, build, draggablePayload, dropTarget }) { + folder({ + name, + count, + iconKind, + path, + startExpanded = false, + forceExpandedWhenFiltered = true, + onContextMenu, + build, + draggablePayload, + dropTarget, + }) { const fullPath = prefix ? `${prefix}/${path}` : path; validPaths.add(fullPath); const folder = buildFolder({ name, count, iconKind, startExpanded, path: fullPath, forceExpanded: isFiltered, + forceExpandedWhenFiltered, onContextMenu, draggablePayload, dropTarget, @@ -1413,7 +1427,19 @@ function isModuleWorkflow(dirPath, wfName) { return isWorkflowModule(dirPath, wfName); } -function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, startExpanded = true, path, forceExpanded = false, draggablePayload, dropTarget }) { +function buildFolder({ + name, + count, + iconKind, + childrenBuilder, + onContextMenu, + startExpanded = true, + path, + forceExpanded = false, + forceExpandedWhenFiltered = true, + draggablePayload, + dropTarget, +}) { // `path` is required — every section/sub-section calling site routes // through `makeSectionCtx`, which always supplies a non-empty // section-prefixed string. The assertion below converts a "future @@ -1427,7 +1453,9 @@ function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, st } // Resolution order: - // 1. forceExpanded (e.g. search active) — overrides everything + // 1. forceExpanded (e.g. search active) opens regular folders, but not + // Archive folders. A crowded Archive should stay summarized under + // search unless the user has explicitly opened or pinned it. // 2. pinnedPaths (a save/spotlight flow has flagged this for force-open // on the current render) — Phase 3 writes pins into pathStates AFTER // Phase 2 builds DOM, so without a direct pinnedPaths read here the @@ -1436,16 +1464,15 @@ function buildFolder({ name, count, iconKind, childrenBuilder, onContextMenu, st // on the immediate render. // 3. pathStates (user has previously toggled this folder) // 4. startExpanded (the natural default for this folder) - let initiallyExpanded; - if (forceExpanded) { - initiallyExpanded = true; - } else if (pinnedPaths.has(path)) { - initiallyExpanded = true; - } else if (pathStates.has(path)) { - initiallyExpanded = pathStates.get(path); - } else { - initiallyExpanded = startExpanded; - } + const initiallyExpanded = resolveFolderExpanded({ + forceExpanded, + forceExpandedWhenFiltered, + iconKind, + isPinned: pinnedPaths.has(path), + hasStoredState: pathStates.has(path), + storedState: pathStates.get(path), + startExpanded, + }); // Track the live expanded state in a closure variable instead of // round-tripping through `wrapper.dataset.expanded`. The DOM dataset diff --git a/web/sidebar/tree_expansion.js b/web/sidebar/tree_expansion.js new file mode 100644 index 0000000..3be0889 --- /dev/null +++ b/web/sidebar/tree_expansion.js @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025-2026 Kforge Labs +// SPDX-License-Identifier: GPL-3.0-only + +export function resolveFolderExpanded({ + forceExpanded = false, + forceExpandedWhenFiltered = true, + iconKind = null, + isPinned = false, + hasStoredState = false, + storedState = false, + startExpanded = true, +} = {}) { + if (forceExpanded && forceExpandedWhenFiltered && iconKind !== "archive") return true; + if (isPinned) return true; + if (hasStoredState) return storedState === true; + return startExpanded === true; +}