From 0a4a627366aaa8c26fe8bd4aaa73ce9bad38bf47 Mon Sep 17 00:00:00 2001 From: sukuwc Date: Wed, 23 Sep 2026 11:14:01 +0200 Subject: [PATCH] Fix tree folders not closable when they contain the selected config ConfigTree force-expanded every ancestor of the selected config on each recompute, so clicking a folder (including a top-level category) that held the active selection would immediately snap back open. Track folders the user explicitly closed and exclude them from the forced-expand set; the set clears when the selection actually changes to a different config, so a fresh selection still reveals its location. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01619XN25FUTzqut4YbzZPQr --- src/lib/components/tree/ConfigTree.svelte | 41 ++++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/lib/components/tree/ConfigTree.svelte b/src/lib/components/tree/ConfigTree.svelte index 0116a48..6d2c159 100644 --- a/src/lib/components/tree/ConfigTree.svelte +++ b/src/lib/components/tree/ConfigTree.svelte @@ -43,6 +43,12 @@ let treeRoot: Tree.Node; let filteredConfigs: Config[] = []; let manuallyExpanded: string[] = []; + // Nodes the user explicitly collapsed. Without this, forcedNodes below + // would keep re-forcing open any folder that's an ancestor of the + // selected config, making it impossible to close that folder while its + // config stays selected. + let manuallyClosed: Set = new Set(); + let previousSelectedId: string | undefined; function sameIds(a: string[], b: string[]) { if (a.length !== b.length) return false; @@ -52,7 +58,14 @@ function handleExpandedChange(event: CustomEvent) { if (sameIds(manuallyExpanded, event.detail)) return; - manuallyExpanded = event.detail; + const next = event.detail; + for (const id of manuallyExpanded) { + if (!next.includes(id)) manuallyClosed.add(id); + } + for (const id of next) { + manuallyClosed.delete(id); + } + manuallyExpanded = next; } export function debounced(store: Writable, delay = 300) { @@ -87,19 +100,21 @@ $: if (treeRoot) { selectClosestMatch($selected_config, filteredConfigs); const selected = get(selected_config); - // Only one top-level category can be open at a time (see TreeComponent's - // accordion logic). getIncludingNodes always forces the selected config's - // top-level ancestor open so the selection stays visible, but that fights - // a manual click into a *different* category - the forced root keeps - // reappearing and evicts the one the user just opened. Once the user has - // manually opened a root, drop any other root from the forced set so - // their choice sticks; nested (non-root) ancestors are unaffected. - const rootIds = get(treeRoot).children.map((e) => get(e).id); - const manualRoot = manuallyExpanded.find((e) => rootIds.includes(e)); + + // A fresh selection should reveal its location, overriding any folder + // the user previously closed by hand. Re-selecting the same config + // (e.g. toggling a folder it lives in) must not reset that choice. + if (selected?.id !== previousSelectedId) { + manuallyClosed.clear(); + previousSelectedId = selected?.id; + } + + // getIncludingNodes forces every ancestor of the selected config open so + // the selection stays visible. Drop any node the user explicitly closed + // (including a top-level category) so that choice sticks instead of + // snapping back open. const includingNodes = treeRoot.getIncludingNodes(selected?.id); - const forcedNodes = manualRoot - ? includingNodes.filter((e) => !rootIds.includes(e) || e === manualRoot) - : includingNodes; + const forcedNodes = includingNodes.filter((e) => !manuallyClosed.has(e)); treeProps = { root: treeRoot, selected: selected?.id,