Keep file tree folders open when selecting a file

Opening another file collapsed every folder that didn't contain it,
which made browsing several folders tedious. Folders now stay open
until they're collapsed by hand.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-09 10:29:32 +02:00
parent b3477e0365
commit bbaf0d792b
4 changed files with 192 additions and 14 deletions
+21 -14
View File
@@ -1,7 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { Tree } from "@bindings/source/Tree"; import type { Tree } from "@bindings/source/Tree";
import { SvelteMap } from "svelte/reactivity"; import { untrack } from "svelte";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { FolderExpansion } from "@app/lib/fileTreeExpansion";
import {
isFolderExpanded,
openAncestors,
toggleFolder,
} from "@app/lib/fileTreeExpansion";
import FileTreeFile from "@app/components/FileTreeFile.svelte"; import FileTreeFile from "@app/components/FileTreeFile.svelte";
import FileTreeFolder from "@app/components/FileTreeFolder.svelte"; import FileTreeFolder from "@app/components/FileTreeFolder.svelte";
@@ -16,24 +24,23 @@
const { currentPath, tree, fetchTree, onSelect }: Props = $props(); const { currentPath, tree, fetchTree, onSelect }: Props = $props();
// Manual expand/collapse overrides, keyed by folder prefix. Held here // Held here rather than in FileTreeFolder so the state survives the
// rather than in FileTreeFolder so the state survives the virtualizer // virtualizer unmounting off-screen rows.
// unmounting off-screen rows; without an override a folder is expanded const expansion: FolderExpansion = {
// when it contains the open file. opened: new SvelteSet(),
const expandedOverrides = new SvelteMap<string, boolean>(); collapsedOnVisit: new SvelteMap(),
};
let visits = 0;
const location = $derived.by(() => ({ path: currentPath, visit: ++visits }));
$effect(() => { $effect(() => {
// Navigating to another file resets every folder to its default, the const path = currentPath;
// same behaviour the per-folder writable $derived had before the state untrack(() => openAncestors(expansion, path));
// was hoisted.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
currentPath;
expandedOverrides.clear();
}); });
function isExpanded(prefix: string): boolean { function isExpanded(prefix: string): boolean {
return expandedOverrides.get(prefix) ?? currentPath.indexOf(prefix) === 0; return isFolderExpanded(expansion, prefix, location);
} }
function toggleExpanded(prefix: string) { function toggleExpanded(prefix: string) {
expandedOverrides.set(prefix, !isExpanded(prefix)); toggleFolder(expansion, prefix, location);
} }
</script> </script>
+52
View File
@@ -0,0 +1,52 @@
// Expansion state of the file tree's folders, keyed by folder prefix
// (`docs/`, `docs/guides/`). A folder stays open once it was opened by hand
// or held the open file, until it is collapsed by hand. Folders containing
// the open file are always expanded, unless collapsed since navigating to
// that file. `visit` counts navigations, so that coming back to the same file
// expands its folders again.
export interface FolderExpansion {
opened: Set<string>;
collapsedOnVisit: Map<string, number>;
}
export interface Location {
path: string;
visit: number;
}
function ancestorPrefixes(path: string): string[] {
const prefixes: string[] = [];
for (let i = path.indexOf("/"); i !== -1; i = path.indexOf("/", i + 1)) {
prefixes.push(path.slice(0, i + 1));
}
return prefixes;
}
export function openAncestors(state: FolderExpansion, path: string) {
for (const prefix of ancestorPrefixes(path)) {
state.opened.add(prefix);
}
}
export function isFolderExpanded(
state: FolderExpansion,
prefix: string,
location: Location,
): boolean {
if (state.collapsedOnVisit.get(prefix) === location.visit) return false;
return state.opened.has(prefix) || location.path.startsWith(prefix);
}
export function toggleFolder(
state: FolderExpansion,
prefix: string,
location: Location,
) {
if (isFolderExpanded(state, prefix, location)) {
state.opened.delete(prefix);
state.collapsedOnVisit.set(prefix, location.visit);
} else {
state.opened.add(prefix);
state.collapsedOnVisit.delete(prefix);
}
}
+24
View File
@@ -75,6 +75,30 @@ test("selecting a file keeps the open folder's entries rendered", async ({
expect(await sibling?.evaluate(el => el.isConnected)).toBe(true); expect(await sibling?.evaluate(el => el.isConnected)).toBe(true);
}); });
test("folders stay open when selecting a file elsewhere", async ({
page,
peer,
}) => {
const { rid } = await createNestedDocsRepo(peer);
await page.goto(`/repos/${rid}/home`);
await page.getByText("guides", { exact: true }).click();
await page.getByText("setup.md", { exact: true }).click();
await expect(page.getByText("Install it first.")).toBeVisible();
await page.getByText("api", { exact: true }).click();
await page.getByText("index.md", { exact: true }).click();
await expect(page.getByText("The API reference.")).toBeVisible();
await expect(page.getByText("usage.md", { exact: true })).toBeVisible();
await page.getByText("guides", { exact: true }).click();
await expect(page.getByText("usage.md", { exact: true })).toBeHidden();
await page.getByText("README.md", { exact: true }).click();
await expect(page.getByText("Welcome to the docs.")).toBeVisible();
await expect(page.getByText("usage.md", { exact: true })).toBeHidden();
await expect(page.getByText("index.md", { exact: true })).toBeVisible();
});
test("commits are listed and open with their changes", async ({ test("commits are listed and open with their changes", async ({
page, page,
peer, peer,
+95
View File
@@ -0,0 +1,95 @@
import { expect, test } from "vitest";
import type { FolderExpansion, Location } from "@app/lib/fileTreeExpansion";
import {
isFolderExpanded,
openAncestors,
toggleFolder,
} from "@app/lib/fileTreeExpansion";
function create(): FolderExpansion {
return { opened: new Set(), collapsedOnVisit: new Map() };
}
let visits = 0;
function navigate(state: FolderExpansion, path: string): Location {
openAncestors(state, path);
return { path, visit: ++visits };
}
test("folders containing the open file are expanded", () => {
const state = create();
const location = { path: "docs/guides/setup.md", visit: 0 };
expect(isFolderExpanded(state, "docs/", location)).toBe(true);
expect(isFolderExpanded(state, "docs/guides/", location)).toBe(true);
expect(isFolderExpanded(state, "src/", location)).toBe(false);
});
test("only the open file's own ancestors are expanded", () => {
const state = create();
const location = navigate(state, "src/doc/readme.md");
expect(isFolderExpanded(state, "doc/", location)).toBe(false);
expect(isFolderExpanded(state, "src/do/", location)).toBe(false);
});
test("a file at the root opens no folders", () => {
const state = create();
navigate(state, "README.md");
expect(state.opened.size).toBe(0);
});
test("folders stay open after navigating to a file elsewhere", () => {
const state = create();
navigate(state, "docs/guides/setup.md");
const location = navigate(state, "src/main.ts");
expect(isFolderExpanded(state, "docs/", location)).toBe(true);
expect(isFolderExpanded(state, "docs/guides/", location)).toBe(true);
});
test("a folder opened by hand stays open after navigating", () => {
const state = create();
toggleFolder(state, "src/", navigate(state, "README.md"));
const location = navigate(state, "docs/setup.md");
expect(isFolderExpanded(state, "src/", location)).toBe(true);
});
test("a folder collapsed by hand stays closed after navigating elsewhere", () => {
const state = create();
toggleFolder(state, "docs/", navigate(state, "docs/setup.md"));
const location = navigate(state, "src/main.ts");
expect(isFolderExpanded(state, "docs/", location)).toBe(false);
});
test("the open file's folder can be collapsed and expanded again", () => {
const state = create();
const location = navigate(state, "docs/setup.md");
toggleFolder(state, "docs/", location);
expect(isFolderExpanded(state, "docs/", location)).toBe(false);
toggleFolder(state, "docs/", location);
expect(isFolderExpanded(state, "docs/", location)).toBe(true);
});
test("navigating into a collapsed folder expands it", () => {
const state = create();
toggleFolder(state, "docs/", navigate(state, "docs/setup.md"));
const location = navigate(state, "docs/usage.md");
expect(isFolderExpanded(state, "docs/", location)).toBe(true);
});
test("navigating back to the file a folder was collapsed on expands it", () => {
const state = create();
toggleFolder(state, "docs/", navigate(state, "docs/setup.md"));
navigate(state, "src/main.ts");
const location = navigate(state, "docs/setup.md");
expect(isFolderExpanded(state, "docs/", location)).toBe(true);
});