web(sidebar): auto-expand folder tree ancestors of the active folder

When the timeline navigates to a nested folder (RightSidebar's
open-folder icon, URL hydration, back/forward), the LeftSidebar
already highlighted the matching row via filters.folderPath — but
if the parent folder was collapsed in the persisted openSet, the
highlighted row wasn't visible at all.

Each FolderTree instance now runs an effect that adds every
ancestor of the active path to its openSet on filter change. The
root instance expands the top-level ancestor first, which mounts
the next-depth FolderTree instance — and the same effect runs
there, cascading down to the leaf. Persisted to localStorage so
the expansion sticks across reloads.

Skipped in `readonly` mode (heap-convert picker has its own
selectedPath and shouldn't drive the sidebar state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:12:36 +02:00
parent 29f7ad7073
commit 9fc650fb12

View File

@@ -40,6 +40,7 @@
<script lang="ts"> <script lang="ts">
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte'; import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte'; import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -105,6 +106,37 @@
if (selectedPath !== undefined) return selectedPath === path; if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === path; return filters.folderPath === path;
} }
// Auto-expand the ancestor chain of the active folder so the
// highlighted row is actually visible after a deep-link navigation
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
// FolderTree instance only owns the openSet entries for the nodes
// rendered at its depth, but since the root instance expands the
// top-level ancestor first, the child instance for that subtree is
// then mounted and runs the same effect — the cascade naturally
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
// picker has its own selectedPath and shouldn't drive the sidebar
// state). Skipped for top-level paths (nothing to expand).
$effect(() => {
if (readonly || !browser) return;
const fp = selectedPath ?? filters.folderPath;
if (!fp || fp === '/' || !fp.includes('/')) return;
untrack(() => {
const parts = fp.split('/');
let changed = false;
for (let i = 1; i < parts.length; i++) {
const ancestor = parts.slice(0, i).join('/');
if (ancestor && !openSet.has(ancestor)) {
openSet.add(ancestor);
changed = true;
}
}
if (changed) {
openSet = new Set(openSet);
persist();
}
});
});
</script> </script>
<ul> <ul>