From 4a806c0a0250e8d3ba174f56c4d6dd264b89bf69 Mon Sep 17 00:00:00 2001 From: Yaphet Y Date: Sun, 25 Oct 2026 21:54:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=B1=82=EF=BC=9ABoardSu?= =?UTF-8?q?mmary/Board=20=E5=A2=9E=E5=8A=A0=20sessionCount=EF=BC=8C?= =?UTF-8?q?=E5=AD=98=E6=A1=A3=E6=97=B6=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/board-store.ts | 1 + src/api.ts | 1 + src/model/progress.ts | 4 ++-- src/model/sessions.ts | 24 ++++++++++++++++++++++++ src/shared/types.ts | 3 +++ src/shared/validation.ts | 1 + tests/board-store.test.ts | 20 ++++++++++++++++++-- tests/e2e/fake-atlas.ts | 3 ++- tests/sessions.test.ts | 32 ++++++++++++++++++++++++++++++++ 9 files changed, 84 insertions(+), 5 deletions(-) diff --git a/electron/board-store.ts b/electron/board-store.ts index 94ecea0..2e3b503 100644 --- a/electron/board-store.ts +++ b/electron/board-store.ts @@ -161,6 +161,7 @@ export class BoardStore { result.push({ id: board.id, title: board.title, + sessionCount: board.sessionCount, kind: board.kind, updatedAt: board.updatedAt, ...(board.archivedAt ? { archivedAt: board.archivedAt } : {}), diff --git a/src/api.ts b/src/api.ts index 9b2f7d7..78421c8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -39,6 +39,7 @@ const preview: AtlasAPI = { return read().map((b) => ({ id: b.id, title: b.title, + sessionCount: b.sessionCount, kind: b.kind, updatedAt: b.updatedAt, ...(b.archivedAt ? { archivedAt: b.archivedAt } : {}), diff --git a/src/model/progress.ts b/src/model/progress.ts index c212881..0bcdc36 100644 --- a/src/model/progress.ts +++ b/src/model/progress.ts @@ -12,5 +12,5 @@ export function boardProgress(board: Pick): BoardProgre return { sealed, total, done: Boolean(board.wrap?.done) || (total > 0 && sealed === total) }; } -/** Save-time stamp; also the backfill for boards saved before progress existed. */ -export const withProgress = (board: B): B => ({ ...board, progress: boardProgress(board) }); +/** Save-time metadata. listSessions currently includes every note, including saved annotations. */ +export const withProgress = (board: B): B => ({ ...board, progress: boardProgress(board), sessionCount: board.notes.length }); diff --git a/src/model/sessions.ts b/src/model/sessions.ts index 7a5b13b..5fe9af6 100644 --- a/src/model/sessions.ts +++ b/src/model/sessions.ts @@ -109,3 +109,27 @@ export function sessionClock(iso: string, now = new Date()): string { return `${d.getMonth() + 1}月${d.getDate()}日`; return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; } + +/** Keep the latest six and the active conversation in activity order. */ +export function visibleSessions( + entries: SessionEntry[], + activeId: string | undefined, + expandedAll: boolean, +): { rows: SessionEntry[]; hidden: number } { + const sorted = [...entries].sort((a, b) => b.time.localeCompare(a.time)); + const rows = + expandedAll || sorted.length <= 8 + ? sorted + : sorted.filter((entry, i) => i < 6 || entry.id === activeId); + return { rows, hidden: entries.length - rows.length }; +} + +export function filterSessions( + entries: SessionEntry[], + query: string, +): SessionEntry[] { + const q = query.trim().toLocaleLowerCase(); + return entries.filter((entry) => + `${entry.title} ${entry.anchorTitle}`.toLocaleLowerCase().includes(q), + ); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index bb8809f..042693d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -245,6 +245,7 @@ export interface Board { wrap?: { text: string; lit: string[]; missing?: string; done: boolean }; /** Stamped on save for the sidebar list (model/progress.ts); older boards are backfilled on list. */ progress?: BoardProgress; + sessionCount?: number; } export type ProbeVerdict = "correct" | "partial" | "wrong" | "unsure"; /** One concept point of the probe's internal chain (shallow → deep). Never shown as a level. */ @@ -321,6 +322,8 @@ export interface BoardPlan { outOfScope: string[]; } export interface BoardSummary { + /** Unknown on legacy saves until loaded or saved. */ + sessionCount?: number; id: string; title: string; kind: Board["kind"]; diff --git a/src/shared/validation.ts b/src/shared/validation.ts index 7d58d76..72d8b55 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -475,6 +475,7 @@ export const boardSchema = z createdAt: timestampSchema, updatedAt: timestampSchema, archivedAt: timestampSchema.optional(), + sessionCount: z.number().int().min(0).optional(), progress: z .object({ sealed: z.number().int().min(0), total: z.number().int().min(0), done: z.boolean() }) .strict() diff --git a/tests/board-store.test.ts b/tests/board-store.test.ts index fa397e7..584258a 100644 --- a/tests/board-store.test.ts +++ b/tests/board-store.test.ts @@ -12,7 +12,7 @@ import { join } from "node:path"; import { boardProgress, withProgress } from "../src/model/progress"; import { tmpdir } from "node:os"; import { BoardStore } from "../electron/board-store"; -import { createEmptyBoard, createWalkthroughBoard } from "../src/model/boards"; +import { createEmptyBoard, createWalkthroughBoard, newNote } from "../src/model/boards"; import { LIMITS } from "../src/shared/validation"; let directory: string; @@ -42,6 +42,7 @@ describe("atomic private board storage", () => { { id: board.id, title: board.title, + sessionCount: board.notes.length, kind: board.kind, updatedAt: board.updatedAt, ...boardProgress(board), @@ -169,6 +170,21 @@ describe("atomic private board storage", () => { code: "TOO_LARGE", }); }); + it("recomputes session counts on save and preserves unknown legacy counts", async () => { + const board = createEmptyBoard("会话数量"); + board.notes.push(newNote(board, { x: 0, y: 0 })); + board.sessionCount = 99; + await store.save(board); + expect((await store.load(board.id))?.sessionCount).toBe(1); + expect((await store.list())[0].sessionCount).toBe(1); + board.notes = []; + await store.save(board); + expect((await store.list())[0].sessionCount).toBe(0); + const path = join(directory, "boards", `${board.id}.json`); + const { sessionCount, ...legacy } = JSON.parse(await readFile(path, "utf8")); + await writeFile(path, JSON.stringify(legacy)); + expect((await store.list())[0].sessionCount).toBeUndefined(); + }); it("backfills sealed/total for boards saved before progress existed", async () => { const board = createEmptyBoard("旧画布"); board.nodes.push({ ...board.nodes[0], id: "c1", parentId: board.nodes[0].id, status: "explained" }); @@ -178,6 +194,6 @@ describe("atomic private board storage", () => { expect(progress).toEqual({ sealed: 1, total: 1, done: true }); await writeFile(path, JSON.stringify(legacy)); const [summary] = await new BoardStore(join(directory, "boards")).list(); - expect(summary).toMatchObject({ sealed: 1, total: 1, done: true }); + expect(summary).toMatchObject({ sealed: 1, total: 1, done: true, sessionCount: board.notes.length }); }); }); diff --git a/tests/e2e/fake-atlas.ts b/tests/e2e/fake-atlas.ts index b40aebf..4b8cbfe 100644 --- a/tests/e2e/fake-atlas.ts +++ b/tests/e2e/fake-atlas.ts @@ -285,13 +285,14 @@ export async function installFakeAtlas( onBeforeClose: () => () => {}, listBoards: async () => [...boards.values()].map( - ({ id, title, kind, updatedAt, archivedAt, progress, nodes, wrap }) => { + ({ id, title, kind, updatedAt, archivedAt, progress, nodes, wrap, notes }) => { // Mirrors model/progress.ts (stored on save, backfilled on list), as the real store does. const cards = nodes.filter((n: any) => n.parentId !== null); const sealed = cards.filter((n: any) => n.status === "explained").length; return { id, title, + sessionCount: (window as any).__atlasLegacyCounts ? undefined : notes.length, kind, updatedAt, ...(archivedAt ? { archivedAt } : {}), diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts index d9848d6..66c8bdd 100644 --- a/tests/sessions.test.ts +++ b/tests/sessions.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { createWalkthroughBoard, newNote } from "../src/model/boards"; import { latestNoteOn, + visibleSessions, + filterSessions, listSessions, sessionClock, stripOrder, @@ -153,3 +155,33 @@ describe("drawer session strip", () => { ]); }); }); + +describe("sidebar session window", () => { + const entries = Array.from({ length: 12 }, (_, i) => ({ + ...listSessions(board().b)[0], + id: `s-${i}`, + title: `Title ${i}`, + anchorTitle: i === 9 ? "特别锚点" : "卡片", + time: new Date(2026, 0, 12 - i).toISOString(), + })); + it("keeps six recent rows and an older active session, without duplicates", () => { + expect(visibleSessions(entries, "s-9", false)).toEqual({ + rows: [...entries.slice(0, 6), entries[9]], + hidden: 5, + }); + expect(visibleSessions(entries, "s-0", false).hidden).toBe(6); + expect( + visibleSessions(entries.slice(0, 8), undefined, false).rows, + ).toHaveLength(8); + expect(visibleSessions(entries, undefined, true).hidden).toBe(0); + expect(visibleSessions([], undefined, false)).toEqual({ + rows: [], + hidden: 0, + }); + }); + it("filters titles and anchors without changing activity order", () => { + expect(filterSessions(entries, " 特别 ")).toEqual([entries[9]]); + expect(filterSessions(entries, "TITLE 11")).toEqual([entries[11]]); + expect(filterSessions(entries, "missing")).toEqual([]); + }); +}); From a674352f661c7897f2d9eb6941c5ae903558d321 Mon Sep 17 00:00:00 2001 From: Yaphet Y Date: Sun, 25 Oct 2026 21:54:12 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E5=88=97=E8=A1=A8=EF=BC=9A=E5=B8=B8=E6=98=BE?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4=E4=B8=8E=E6=95=B0=E9=87=8F=E3=80=81=E4=B8=A4?= =?UTF-8?q?=E8=A1=8C=E4=BC=9A=E8=AF=9D=E3=80=81=E5=B1=95=E5=BC=80=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E3=80=81=E6=90=9C=E7=B4=A2=E3=80=81=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E8=B7=A8=E7=94=BB=E5=B8=83=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 30 ++- src/components/WorkspaceSidebar.tsx | 334 ++++++++++++++++++++++++---- 2 files changed, 309 insertions(+), 55 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5bc8482..a7bf99e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1630,18 +1630,36 @@ export default function App() { boardId: string, sessionId: string, ): Promise { - if (locked()) return; + if (locked()) throw new Error("正在切换画布"); if (boardRef.current?.id !== boardId) await switchBoard(boardId); const current = boardRef.current; - if (current?.id !== boardId) return; + if (current?.id !== boardId) throw new Error("无法打开画布"); const target = current.notes.find((note) => note.id === sessionId); - if (!target) return; + if (!target) throw new Error("对话不存在"); openDrawer(sessionId); const at = target.attachedTo; - if (at) setSelection(at); - if (at?.kind === "node") { + const anchorExists = + at?.kind === "node" + ? current.nodes.some((n) => n.id === at.id) + : at?.kind === "relation" + ? current.relations.some((r) => r.id === at.id) + : false; + if (at && anchorExists) setSelection(at); + if (at && !anchorExists) { + setSelection(null); + setTimeout(() => flow.fitView({ padding: 0.16, duration: 400 }), 80); + showToast("锚点卡已不在画布上"); + } + if (at?.kind === "node" && anchorExists) { // Let React Flow render the selected board before locating its card. setTimeout(() => frameFamily(at.id, true), 80); + } else if (at?.kind === "relation" && anchorExists) { + const relation = current.relations.find((r) => r.id === at.id)!; + setTimeout(() => flow.fitView({ + nodes: [{ id: relation.source }, { id: relation.target }], + padding: 0.2, + duration: 320, + }), 80); } } async function createBoard(walkthrough = false) { @@ -3017,7 +3035,7 @@ export default function App() { sessions={listSessions(board)} currentSessionId={drawer?.id} onOpenSession={(boardId, sessionId) => - void openSession(boardId, sessionId) + openSession(boardId, sessionId) } onNewSession={() => addNote(undefined, { fresh: true })} /> diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index e8314c9..c4ff0f6 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -9,6 +9,7 @@ import { createPortal } from "react-dom"; import { Archive, ChevronRight, + ChevronDown, Compass, LoaderCircle, MessageSquare, @@ -22,12 +23,16 @@ import type { BoardSummary } from "../shared/types"; import { api } from "../api"; import { listSessions, + visibleSessions, + filterSessions, sessionClock, type SessionEntry, } from "../model/sessions"; import { MOD } from "../model/shortcuts"; import { tip } from "./Tooltip"; +const SESSION_VIEW_KEY = "studyboard.sidebar.sessions.v1"; + export const SIDEBAR_DEFAULT_WIDTH = 256; export const SIDEBAR_MIN_WIDTH = 200; export const SIDEBAR_MAX_WIDTH = 400; @@ -52,7 +57,7 @@ type WorkspaceSidebarProps = { onSettings: () => void; sessions: SessionEntry[]; currentSessionId?: string; - onOpenSession: (boardId: string, sessionId: string) => void; + onOpenSession: (boardId: string, sessionId: string) => Promise; onNewSession: () => void; }; @@ -65,7 +70,7 @@ type BoardGroupProps = { currentSessionId?: string; onPick: () => void; onContextMenu: (event: React.MouseEvent) => void; - onOpenSession: (sessionId: string) => void; + onOpenSession: (sessionId: string) => Promise; onNewSession: () => void; }; @@ -86,20 +91,79 @@ function BoardGroup({ onOpenSession, onNewSession, }: BoardGroupProps): ReactElement { - const [expanded, setExpanded] = useState(false); + const [view, setView] = useState(() => { + try { + return ( + JSON.parse(localStorage.getItem(SESSION_VIEW_KEY) ?? "{}")[ + board.id + ] ?? { open: false, all: false } + ); + } catch { + return { open: false, all: false }; + } + }); + const expanded = view.open === true; + function updateView(patch: { open?: boolean; all?: boolean }): void { + const next = { ...view, ...patch }; + setView(next); + try { + const saved = JSON.parse(localStorage.getItem(SESSION_VIEW_KEY) ?? "{}"); + localStorage.setItem( + SESSION_VIEW_KEY, + JSON.stringify({ ...saved, [board.id]: next }), + ); + } catch { + /* Storage can be unavailable. */ + } + } + const [query, setQuery] = useState(""); + const [opening, setOpening] = useState(null); + const [openError, setOpenError] = useState(null); + const activeRef = useRef(null); + const cache = useRef<{ + stamp: string; + retry: number; + entries: SessionEntry[]; + } | null>(null); + useEffect(() => { + if (expanded && currentSessionId && current) + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [expanded, currentSessionId, current]); + async function open(id: string): Promise { + if (opening || switchingId) return; + setOpening(id); + setOpenError(null); + try { + await onOpenSession(id); + updateView({ open: true }); + } catch { + setOpenError(id); + } finally { + setOpening(null); + } + } const [savedSessions, setSavedSessions] = useState({ status: "loading", }); const [retry, setRetry] = useState(0); useEffect(() => { if (current || !expanded || collapsed) return; + if ( + cache.current?.stamp === board.updatedAt && + cache.current.retry === retry + ) { + setSavedSessions({ status: "ready", entries: cache.current.entries }); + return; + } let cancelled = false; setSavedSessions({ status: "loading" }); void api.loadBoard(board.id).then( (value) => { if (cancelled) return; if (value) { - setSavedSessions({ status: "ready", entries: listSessions(value) }); + const entries = listSessions(value); + cache.current = { stamp: board.updatedAt, retry, entries }; + setSavedSessions({ status: "ready", entries }); } else { setSavedSessions({ status: "error", message: "画布不存在" }); } @@ -113,55 +177,128 @@ function BoardGroup({ cancelled = true; }; }, [board.id, board.updatedAt, current, expanded, collapsed, retry]); + useEffect(() => { + if (current) + cache.current = { stamp: board.updatedAt, retry, entries: sessions }; + }, [current, board.updatedAt, retry, sessions]); const sessionState: SessionLoadState = current ? { status: "ready", entries: sessions } : savedSessions; - const disabled = switchingId !== null; + const disabled = switchingId !== null || opening !== null; const activeSessionId = current ? currentSessionId : undefined; + const count = current + ? sessions.length + : sessionState.status === "ready" + ? sessionState.entries.length + : board.sessionCount; + const filtered = + sessionState.status === "ready" + ? filterSessions(sessionState.entries, query) + : []; + const { rows, hidden } = visibleSessions( + filtered, + activeSessionId, + view.all || !!query.trim(), + ); + const disclosureLabel = `${expanded ? "收起" : "展开"}对话(${count ?? "未知"})`; let sessionItems: ReactNode; if (sessionState.status === "error") { sessionItems = (
  • - {sessionState.message}。 + 无法读取对话 ·
  • ); } else if (sessionState.status === "loading") { sessionItems = ( -
  • - 正在读取对话… +
  • +
    正在读取对话…
    + {Array.from({ length: Math.min(count ?? 3, 3) }, (_, i) => ( + + ))}
  • ); } else if (!sessionState.entries.length) { sessionItems =
  • 还没有对话
  • ; } else { - sessionItems = sessionState.entries.map((session) => ( -
  • - -
  • - )); + sessionItems = rows.length ? ( + rows.map((session) => ( +
  • + + {openError === session.id && ( +
    + 无法打开 ·{" "} + +
    + )} +
  • + )) + ) : ( +
  • 没有匹配的对话
  • + ); } return ( -
  • -
    +
  • { + if ( + e.target instanceof HTMLInputElement || + (e.target as HTMLElement).closest(".workspace-sessions") + ) + return; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + e.stopPropagation(); + updateView({ open: e.key === "ArrowRight" }); + } + }} + > +
    { + if (!(e.target as HTMLElement).closest("button")) onPick(); + }} + className={`workspace-board-row ${current ? "is-current" : ""} ${count ? "has-sessions" : ""}`} + > {!collapsed && ( )} + {!collapsed && !!count && ( + + )}
    {!collapsed && (