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); +});