fix: persist notebook tree expansion state

Refs #345
This commit is contained in:
tianma-if committed 2026-09-22 10:04:57 +08:00
1 parent 86f6c93183
commit ab44cb18fc
5 files changed
+116 -15

No files matched your search

@@ -99,6 +99,19 @@ describe("NotebookPane sidebar collapse", () => {
});
});
describe("NotebookPane tree expansion persistence", () => {
test("stores collapsed branches outside individual tree items", () => {
const treeItemSource = readFileSync(new URL("./NotebookTreeItem.tsx", import.meta.url), "utf8");
expect(source).toContain("readNotebookTreeCollapsedIdsPreference");
expect(source).toContain("writeNotebookTreeCollapsedIdsPreference(collapsedNotebookIds)");
expect(source).toContain("collapsedNotebookIds={collapsedNotebookIds}");
expect(treeItemSource).toContain("const open = !collapsedNotebookIds.has(node.id)");
expect(treeItemSource).not.toContain("const [open, setOpen] = useState(true)");
expect(treeItemSource).not.toContain("if (hasSelectedDescendant) {");
});
});
describe("NotebookPane client downloads", () => {
test("keeps macOS, Windows, and Linux downloads visible in the desktop runtime", () => {
expect(source).toContain('t("pwa.sidebarMac") || "macOS"');
+25
View File
@@ -55,7 +55,9 @@ import {
getNotebookSortOptions,
getNotebookSortComparator,
hasEdgeEverDragData,
readNotebookTreeCollapsedIdsPreference,
readNotebookSortPreference,
writeNotebookTreeCollapsedIdsPreference,
writeNotebookSortPreference,
} from "@/lib/app-helpers";
import type { EdgeEverRepository } from "@/lib/repository";
@@ -529,6 +531,7 @@ export const NotebookPane = ({
const notebookDragScrollFrameRef = useRef<number | null>(null);
const [expandSiblingsRequest, setExpandSiblingsRequest] = useState<{ parentId: string | null; token: number } | null>(null);
const [notebookSortMode, setNotebookSortMode] = useState<NotebookSortMode>(readNotebookSortPreference);
const [collapsedNotebookIds, setCollapsedNotebookIds] = useState<Set<string>>(readNotebookTreeCollapsedIdsPreference);
const handleMoveNotebook = useCallback((notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => {
setNotebookSortMode("custom");
@@ -605,6 +608,26 @@ export const NotebookPane = ({
writeNotebookSortPreference(notebookSortMode);
}, [notebookSortMode]);
useEffect(() => {
writeNotebookTreeCollapsedIdsPreference(collapsedNotebookIds);
}, [collapsedNotebookIds]);
const handleNotebookOpenChange = useCallback((notebookId: string, open: boolean) => {
setCollapsedNotebookIds((current) => {
if (current.has(notebookId) === !open) {
return current;
}
const next = new Set(current);
if (open) {
next.delete(notebookId);
} else {
next.add(notebookId);
}
return next;
});
}, []);
useEffect(() => {
if (!selectedNotebookId) {
return;
@@ -803,6 +826,8 @@ export const NotebookPane = ({
onMoveNotebook={handleMoveNotebook}
onMoveMemos={onMoveMemos}
onDragScroll={handleNotebookScrollDragOver}
collapsedNotebookIds={collapsedNotebookIds}
onOpenChange={handleNotebookOpenChange}
expandSiblingsRequest={expandSiblingsRequest}
onExpandSiblings={handleExpandNotebookSiblings}
/>
+15 -15
View File
@@ -37,6 +37,8 @@ export const NotebookTreeItem = ({
onMoveNotebook,
onMoveMemos,
onDragScroll,
collapsedNotebookIds,
onOpenChange,
expandSiblingsRequest,
onExpandSiblings,
}: {
@@ -50,11 +52,13 @@ export const NotebookTreeItem = ({
onMoveNotebook: (notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => void;
onMoveMemos: (memoIds: string[], targetNotebookId: string) => void;
onDragScroll: (event: DragEvent<HTMLDivElement>) => void;
collapsedNotebookIds: ReadonlySet<string>;
onOpenChange: (notebookId: string, open: boolean) => void;
expandSiblingsRequest: { parentId: string | null; token: number } | null;
onExpandSiblings: (parentId: string | null) => void;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const open = !collapsedNotebookIds.has(node.id);
const hasChildren = node.children.length > 0;
const selected = node.id === selectedNotebookId;
const isInbox = node.slug === "inbox";
@@ -90,19 +94,13 @@ export const NotebookTreeItem = ({
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [actionsOpen]);
useEffect(() => {
if (hasSelectedDescendant) {
setOpen(true);
}
}, [hasSelectedDescendant]);
useEffect(() => {
if (!expandSiblingsRequest || expandSiblingsRequest.parentId !== node.parentId || !hasChildren) {
return;
}
setOpen(true);
}, [expandSiblingsRequest, hasChildren, node.parentId]);
onOpenChange(node.id, true);
}, [expandSiblingsRequest, hasChildren, node.id, node.parentId, onOpenChange]);
const scheduleDragExpand = (position: NotebookDropPosition) => {
if (!hasChildren || open || position !== "inside") {
@@ -116,7 +114,7 @@ export const NotebookTreeItem = ({
expandTimerRef.current = window.setTimeout(() => {
expandTimerRef.current = null;
setOpen(true);
onOpenChange(node.id, true);
}, NOTEBOOK_DRAG_EXPAND_DELAY_MS);
};
@@ -155,7 +153,7 @@ export const NotebookTreeItem = ({
if (memoIds.length > 0) {
onMoveMemos(memoIds, node.id);
setOpen(true);
onOpenChange(node.id, true);
return;
}
@@ -164,7 +162,7 @@ export const NotebookTreeItem = ({
}
onMoveNotebook(notebookId, node.id, position);
setOpen(true);
onOpenChange(node.id, true);
};
return (
@@ -203,7 +201,7 @@ export const NotebookTreeItem = ({
<button
className="flex h-6 w-5 items-center justify-center rounded hover:bg-slate-100/50 transition-colors"
type="button"
onClick={() => setOpen((value) => !value)}
onClick={() => onOpenChange(node.id, !open)}
title={t("notebookTree.expandCollapse")}
aria-label={open ? t("notebookTree.collapse", { name: node.name }) : t("notebookTree.expand", { name: node.name })}
aria-expanded={open}
@@ -250,14 +248,14 @@ export const NotebookTreeItem = ({
if (event.key === "ArrowRight" && hasChildren && !open) {
event.preventDefault();
event.stopPropagation();
setOpen(true);
onOpenChange(node.id, true);
return;
}
if (event.key === "ArrowLeft" && hasChildren && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
onOpenChange(node.id, false);
return;
}
}}
@@ -397,6 +395,8 @@ export const NotebookTreeItem = ({
onMoveNotebook={onMoveNotebook}
onMoveMemos={onMoveMemos}
onDragScroll={onDragScroll}
collapsedNotebookIds={collapsedNotebookIds}
onOpenChange={onOpenChange}
expandSiblingsRequest={expandSiblingsRequest}
onExpandSiblings={onExpandSiblings}
/>
+40
View File
@@ -10,12 +10,14 @@ import {
EDITOR_PHONE_PREVIEW_STORAGE_KEY,
EDITOR_PHONE_PREVIEW_FOLLOW_STORAGE_KEY,
NOTEBOOK_SORT_STORAGE_KEY,
NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY,
SHORTCUT_SETTINGS_STORAGE_KEY,
getSearchShortcutScope,
getShortcutActionForEvent,
getNotebookSortComparator,
readEditorContentAlignmentPreference,
readNotebookSortPreference,
readNotebookTreeCollapsedIdsPreference,
readDesktopFocusModePreference,
readDesktopReadingProtectionPreference,
readNotebookSidebarCollapsedPreference,
@@ -26,6 +28,7 @@ import {
readShortcutSettingsPreference,
writeEditorContentAlignmentPreference,
writeNotebookSortPreference,
writeNotebookTreeCollapsedIdsPreference,
writeDesktopFocusModePreference,
writeDesktopReadingProtectionPreference,
writeNotebookSidebarCollapsedPreference,
@@ -355,6 +358,43 @@ describe("custom notebook sorting", () => {
});
});
describe("notebook tree collapsed preference", () => {
test("round-trips the user's collapsed notebook branches", () => {
const values = installLocalStorage();
writeNotebookTreeCollapsedIdsPreference(["notebook-2", "notebook-1", "notebook-2"]);
expect(values.get(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY)).toBe('["notebook-2","notebook-1"]');
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set(["notebook-2", "notebook-1"]));
});
test("ignores malformed and invalid stored values", () => {
const values = installLocalStorage();
values.set(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY, "{");
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set());
values.set(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY, JSON.stringify(["notebook-1", "", null, 2]));
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set(["notebook-1"]));
});
test("falls back to expanded when local storage is unavailable", () => {
globalThis.window = {
localStorage: {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
},
};
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set());
expect(() => writeNotebookTreeCollapsedIdsPreference(["notebook-1"])).not.toThrow();
});
});
describe("workspace shortcut preferences", () => {
test("provides navigation, AI, save, reading protection, and editor mode defaults", () => {
expect(DEFAULT_SHORTCUT_SETTINGS.focusGlobalSearch).toEqual({
+23
View File
@@ -140,6 +140,7 @@ export const EDITOR_PHONE_PREVIEW_FOLLOW_STORAGE_KEY = "edgeever.editor.phonePre
export const MEMO_LIST_DENSITY_STORAGE_KEY = "edgeever.memoListDensity";
export const MEMO_LIST_WIDTH_STORAGE_KEY = "edgeever.memoListWidth";
export const NOTEBOOK_SORT_STORAGE_KEY = "edgeever.notebookSort";
export const NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY = "edgeever.notebookTreeCollapsedIds:v1";
export const SHORTCUT_SETTINGS_STORAGE_KEY = "edgeever.shortcutSettings";
export const DEFAULT_MEMO_LIST_WIDTH_PX = 360;
export const MIN_MEMO_LIST_WIDTH_PX = 300;
@@ -516,6 +517,28 @@ export const writeNotebookSortPreference = (sortMode: NotebookSortMode) => {
}
};
export const readNotebookTreeCollapsedIdsPreference = () => {
try {
const value: unknown = JSON.parse(window.localStorage.getItem(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY) ?? "[]");
if (!Array.isArray(value)) {
return new Set<string>();
}
return new Set(value.filter((notebookId): notebookId is string => typeof notebookId === "string" && notebookId.length > 0));
} catch {
return new Set<string>();
}
};
export const writeNotebookTreeCollapsedIdsPreference = (notebookIds: Iterable<string>) => {
try {
window.localStorage.setItem(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY, JSON.stringify(Array.from(new Set(notebookIds))));
} catch {
// Local storage can be unavailable in private or restricted browser contexts.
}
};
export const normalizeShortcutKey = (key: string) => {
if (key === " ") {
return "space";