Render the folder tree on the server

The sidebar tree is part of the page (chain to the current page
expanded, active row marked), so it no longer paints late and the
sessionStorage snapshot is gone; only the rail's scroll position is
kept. Folders are native <details> that lazy-load their children as
HTML via htmx. The tree picker hosts the same server-rendered rows with
radio inputs, so ?tree returns HTML and the JSON listing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:51:20 +02:00
co-authored by Claude Opus 5.5
parent 353df29ee6
commit 4365fa2567
11 changed files with 357 additions and 567 deletions
+91
View File
@@ -179,3 +179,94 @@ test("a failed move shows the server's reason and keeps the dialog", async ({ pa
await expect(modal(page)).toBeVisible();
await expect(page).toHaveURL(/\/Mover\/$/);
});
function mkpage(rel, title) {
mkdirSync(join(wiki, rel), { recursive: true });
writeFileSync(join(wiki, rel, "index.md"), `# ${title}\n`);
}
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
const treeRequests = [];
page.on("request", (r) => {
if (r.url().includes("?tree")) treeRequests.push(r.url());
});
const res = await page.request.get("/Projects/");
expect(await res.text()).toMatch(/class="tree-name tree-folder" href="\/Projects\/"/);
await page.goto("/Projects/");
await expect(sidebar(page).locator(".tree-row.is-active")).toContainText("Projects");
expect(treeRequests).toEqual([]);
});
test("sidebar folders expand lazily and collapse", async ({ page }) => {
mkpage("Tree/One/Deep", "Deep");
mkpage("Tree/Two", "Two");
await page.goto("/");
const treeRow = sidebar(page).locator(".tree-row", { hasText: "Tree" });
const chevron = treeRow.locator(".tree-chevron");
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
await expect(sidebar(page).getByRole("link", { name: "One" })).toHaveCount(0);
expect(await glyph()).toBe('"▸"');
await chevron.click();
await expect(sidebar(page).getByRole("link", { name: "One" })).toBeVisible();
expect(await glyph()).toBe('"▾"');
// One has a subfolder, Two has none: only One gets a chevron.
await expect(sidebar(page).locator("summary.tree-row", { hasText: "One" })).toHaveCount(1);
await expect(sidebar(page).locator(".tree-row", { hasText: "Two" }).locator(".tree-chevron")).toHaveClass(/is-leaf/);
await treeRow.locator(".tree-chevron").click();
await expect(sidebar(page).getByRole("link", { name: "One" })).toBeHidden();
});
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
mkpage("Tree/One/Deep", "Deep");
mkpage("Mover2", "Mover2");
await page.goto("/Mover2/");
await pageAction(page, "Move page (M)");
const picker = modal(page).locator(".tree-picker");
await picker.locator(".tree-row", { hasText: "Tree" }).locator(".tree-chevron").click();
await picker.locator(".tree-row", { hasText: "One" }).locator(".tree-chevron").click();
await picker.getByText("Deep", { exact: true }).click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree/One/Deep");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(/\/Tree\/One\/Deep\/Mover2\/$/);
});
test("tree picker can pick the root", async ({ page }) => {
mkpage("Tree/Two", "Two");
await page.goto("/Tree/Two/");
await pageAction(page, "Move page (M)");
// The current parent is preselected and its chain expanded.
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree");
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill("TwoTop");
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/TwoTop\/$/);
});
test("editor embed picker selects a file", async ({ page }) => {
mkpage("Embeds", "Embeds");
writeFileSync(join(wiki, "Embeds", "photo.jpg"), "x");
await page.goto("/Embeds/?edit");
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+G");
const picker = modal(page).locator(".tree-picker");
// Folder mode rows aren't selectable here; the file is.
await picker.getByText("photo.jpg").click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Embeds/photo.jpg");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
});
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
await page.setViewportSize({ width: 400, height: 800 });
await page.goto("/Projects/");
await expect(sidebar(page)).toBeHidden();
await page.getByRole("button", { name: "ACTIONS" }).click();
await page.getByTitle("Folders").click();
const overlay = page.locator(".overlay");
await expect(overlay.getByRole("link", { name: "Projects" })).toBeVisible();
await overlay.getByRole("link", { name: "Projects" }).click();
await expect(overlay).toHaveCount(0);
});