feat: persist sidebar fold preference across logins

This commit is contained in:
2026-10-01 11:42:49 +09:00
parent eb4c7959a7
commit a07d70b107
4 changed files with 118 additions and 3 deletions
@@ -24,7 +24,9 @@ async function freePort(): Promise<number> {
async function waitForServer(url: string): Promise<void> { async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) { for (let attempt = 0; attempt < 200; attempt += 1) {
try { try {
if ((await fetch(url)).ok) return; const response = await fetch(url);
await response.body?.cancel();
if (response.ok) return;
} catch { } catch {
// Retry until the bounded deadline while the owned server starts. // Retry until the bounded deadline while the owned server starts.
} }
@@ -334,6 +336,17 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
assertEquals(new Set(rowIds).size, rowIds.length); assertEquals(new Set(rowIds).size, rowIds.length);
await page.getByRole("button", { name: "Overview", exact: true }).click(); await page.getByRole("button", { name: "Overview", exact: true }).click();
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
await page.getByRole("button", { name: "Fold sidebar", exact: true }).click();
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true");
await page.reload();
await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.setViewportSize({ width: 600, height: viewportHeight });
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false");
await page.reload();
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1);
assertEquals(errors, []); assertEquals(errors, []);
await context.close(); await context.close();
} finally { } finally {
@@ -1329,7 +1329,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
sidebarFrame.includes("sidebar-fold-button") && sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") && sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") && sidebarFrame.includes("Unfold sidebar") &&
rootLayout.includes("let sidebarFolded = $state(false)") && rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") && rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
!workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") && !workspaceLayout.includes("onToggleFold") &&
+21 -1
View File
@@ -24,7 +24,27 @@
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => { const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
header = activeHeader; header = activeHeader;
}); });
let sidebarFolded = $state(false); // Browser-wide layout preference; independent of authentication and Workspace.
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1';
function loadSidebarFolded(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(sidebarFoldStorageKey) === 'true';
} catch {
return false;
}
}
let sidebarFolded = $state(loadSidebarFolded());
$effect(() => {
const folded = sidebarFolded;
try {
window.localStorage.setItem(sidebarFoldStorageKey, String(folded));
} catch {
// Storage can be blocked or full; folding must still work for this page.
}
});
function toggleSidebar() { function toggleSidebar() {
sidebarFolded = !sidebarFolded; sidebarFolded = !sidebarFolded;
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet } from "svelte";
import { logout } from "$lib/workspace/auth/api";
import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
const storageKey = "yoi.sidebar.folded.v1";
const props = {
params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
};
beforeEach(() => localStorage.clear());
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
});
test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => {
const view = render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true");
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("true");
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("false");
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("true");
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("false");
});
test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => {
localStorage.setItem(storageKey, String(folded));
const writes = vi.spyOn(Storage.prototype, "setItem");
const view = render(Layout, { props });
expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded));
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded);
expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true);
});
test("fold preference survives logout and a fresh app mount", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
const view = render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
await logout();
view.unmount();
render(Layout, { props });
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
expect(localStorage.getItem(storageKey)).toBe("true");
});
test("invalid storage falls back to unfolded", () => {
localStorage.setItem(storageKey, "not-a-boolean");
render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
});
test("unavailable storage does not prevent folding", async () => {
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
});
test("full storage still restores the saved value and allows toggles", async () => {
localStorage.setItem(storageKey, "true");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
});