+
diff --git a/apps/web/lib/workbench/useChapterListCollapse.test.ts b/apps/web/lib/workbench/useChapterListCollapse.test.ts
new file mode 100644
index 0000000..f58d82a
--- /dev/null
+++ b/apps/web/lib/workbench/useChapterListCollapse.test.ts
@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ CHAPTER_LIST_COLLAPSE_KEY,
+ useChapterListCollapse,
+} from "./useChapterListCollapse";
+
+// 本仓库 jsdom 未提供可用的 Storage(setItem/clear 缺失),装一个内存版 localStorage。
+function installMemoryStorage(): Storage {
+ const map = new Map();
+ const storage: Storage = {
+ get length() {
+ return map.size;
+ },
+ clear: () => map.clear(),
+ getItem: (key) => (map.has(key) ? (map.get(key) ?? null) : null),
+ key: (index) => Array.from(map.keys())[index] ?? null,
+ removeItem: (key) => map.delete(key),
+ setItem: (key, value) => map.set(key, String(value)),
+ };
+ Object.defineProperty(window, "localStorage", {
+ value: storage,
+ configurable: true,
+ });
+ return storage;
+}
+
+describe("useChapterListCollapse", () => {
+ beforeEach(() => {
+ installMemoryStorage();
+ });
+ afterEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("默认展开(无存档时 collapsed=false)", () => {
+ const { result } = renderHook(() => useChapterListCollapse());
+ expect(result.current.collapsed).toBe(false);
+ });
+
+ it("toggle 翻转并把状态写入 localStorage", () => {
+ const { result } = renderHook(() => useChapterListCollapse());
+
+ act(() => result.current.toggle());
+
+ expect(result.current.collapsed).toBe(true);
+ expect(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY)).toBe("1");
+
+ act(() => result.current.toggle());
+
+ expect(result.current.collapsed).toBe(false);
+ expect(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY)).toBe("0");
+ });
+
+ it("挂载时从 localStorage 恢复已收起状态", () => {
+ window.localStorage.setItem(CHAPTER_LIST_COLLAPSE_KEY, "1");
+
+ const { result } = renderHook(() => useChapterListCollapse());
+
+ expect(result.current.collapsed).toBe(true);
+ });
+});
diff --git a/apps/web/lib/workbench/useChapterListCollapse.ts b/apps/web/lib/workbench/useChapterListCollapse.ts
new file mode 100644
index 0000000..cb534e3
--- /dev/null
+++ b/apps/web/lib/workbench/useChapterListCollapse.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+// 「目录」列折叠偏好的持久化键(对齐既有 `ww.` 前缀约定)。
+export const CHAPTER_LIST_COLLAPSE_KEY = "ww.workbench_chapter_list_collapsed";
+
+export interface ChapterListCollapse {
+ // true=目录列收起(让宽给正文);false=展开。
+ collapsed: boolean;
+ toggle: () => void;
+}
+
+function persist(collapsed: boolean): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(CHAPTER_LIST_COLLAPSE_KEY, collapsed ? "1" : "0");
+ } catch {
+ // 隐私模式/配额异常忽略:折叠是纯 UI 偏好,写盘失败不影响导航功能。
+ }
+}
+
+// 记住「目录」列收起/展开状态(P2-2):SSR 首帧恒展开(避免 hydration 不一致),
+// 挂载后再从 localStorage 恢复;toggle 即时写回。仅桌面(≥xl)用得上,窄屏走抽屉不受影响。
+export function useChapterListCollapse(): ChapterListCollapse {
+ const [collapsed, setCollapsed] = useState(false);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ try {
+ setCollapsed(window.localStorage.getItem(CHAPTER_LIST_COLLAPSE_KEY) === "1");
+ } catch {
+ // 读盘失败则维持默认展开。
+ }
+ }, []);
+
+ const toggle = useCallback(() => {
+ setCollapsed((prev) => {
+ const next = !prev;
+ persist(next);
+ return next;
+ });
+ }, []);
+
+ return { collapsed, toggle };
+}