diff --git a/src/components/Tree.svelte b/src/components/Tree.svelte
index c4c17684c..08435b20f 100644
--- a/src/components/Tree.svelte
+++ b/src/components/Tree.svelte
@@ -1,7 +1,15 @@
diff --git a/src/lib/fileTreeExpansion.ts b/src/lib/fileTreeExpansion.ts
new file mode 100644
index 000000000..ea5c6a5e9
--- /dev/null
+++ b/src/lib/fileTreeExpansion.ts
@@ -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;
+ collapsedOnVisit: Map;
+}
+
+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);
+ }
+}
diff --git a/tests/e2e/repo/source.spec.ts b/tests/e2e/repo/source.spec.ts
index 26ebf1380..c23997b49 100644
--- a/tests/e2e/repo/source.spec.ts
+++ b/tests/e2e/repo/source.spec.ts
@@ -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);
});
+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 ({
page,
peer,
diff --git a/tests/unit/fileTreeExpansion.test.ts b/tests/unit/fileTreeExpansion.test.ts
new file mode 100644
index 000000000..93befd1ea
--- /dev/null
+++ b/tests/unit/fileTreeExpansion.test.ts
@@ -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);
+});