From 02b9a02a9021a85717f506182f349dc472c44ee9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 23 Dec 2025 00:48:47 -0600 Subject: [PATCH] feat: add FileBrowser component for managing boards --- TODO.txt | 9 +- apps/web/src/lib/canvas/Canvas.svelte | 105 +- apps/web/src/lib/canvas/canvas-helpers.ts | 91 ++ .../web/src/lib/canvas/canvas-store.svelte.ts | 1055 +++++------------ .../desktop-file-controller.svelte.ts | 117 ++ .../filebrowser-controller.svelte.ts | 56 + .../canvas/controllers/history-controller.ts | 15 + .../texteditor-controller.svelte.ts | 105 ++ .../controllers/tool-controller.svelte.ts | 18 + .../lib/canvas/store/handle-state.svelte.ts | 8 + .../src/lib/canvas/store/pan-state.svelte.ts | 5 + .../lib/canvas/store/pointer-state.svelte.ts | 4 + apps/web/src/lib/components/TitleBar.svelte | 10 +- .../src/lib/filebrowser/FileBrowser.svelte | 662 +++++++++++ apps/web/src/lib/tests/Canvas.history.test.ts | 12 +- .../web/src/lib/tests/Canvas.keyboard.test.ts | 81 +- .../components/FileBrowser.svelte.test.ts | 305 +++++ packages/core/src/index.ts | 1 + packages/core/src/persistence/db.ts | 5 + packages/core/src/persistence/stats.ts | 61 + packages/core/src/persistence/web.ts | 69 ++ packages/core/tests/stats.test.ts | 108 ++ 22 files changed, 2063 insertions(+), 839 deletions(-) create mode 100644 apps/web/src/lib/canvas/canvas-helpers.ts create mode 100644 apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts create mode 100644 apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts create mode 100644 apps/web/src/lib/canvas/controllers/history-controller.ts create mode 100644 apps/web/src/lib/canvas/controllers/texteditor-controller.svelte.ts create mode 100644 apps/web/src/lib/canvas/controllers/tool-controller.svelte.ts create mode 100644 apps/web/src/lib/canvas/store/handle-state.svelte.ts create mode 100644 apps/web/src/lib/canvas/store/pan-state.svelte.ts create mode 100644 apps/web/src/lib/canvas/store/pointer-state.svelte.ts create mode 100644 apps/web/src/lib/filebrowser/FileBrowser.svelte create mode 100644 apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts create mode 100644 packages/core/src/persistence/stats.ts create mode 100644 packages/core/tests/stats.test.ts diff --git a/TODO.txt b/TODO.txt index c8bf125..057702e 100644 --- a/TODO.txt +++ b/TODO.txt @@ -198,13 +198,12 @@ R2. Web: Boards list + Dexie "Inspector" drawer - open / create / rename / delete Inspector drawer (selected board): -[ ] Show "Storage: IndexedDB (Dexie)" +[ ] Show "Storage" [ ] Show schema info: - - declared schema version (your constant) - - installed schema version (best-effort display) + - declared schema version + - installed schema version [ ] Show board-level stats (computed live): - - row counts: pages/shapes/bindings for this board (Option A) - OR doc size bytes for docs row (Option B) + - row counts: pages/shapes/bindings for this board & doc size bytes for docs row - last updatedAt [ ] Show migration info: - list applied migrations from migrations table (id + appliedAt) diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index a81295f..1566028 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -3,61 +3,31 @@ import StatusBar from '$lib/components/StatusBar.svelte'; import TitleBar from '$lib/components/TitleBar.svelte'; import Toolbar from '$lib/components/Toolbar.svelte'; + import FileBrowser from '$lib/filebrowser/FileBrowser.svelte'; import { createCanvasController } from './canvas-store.svelte.ts'; let canvasEl = $state(null); let textEditorEl = $state(null); let historyViewerOpen = $state(false); - const controller = createCanvasController({ + const c = createCanvasController({ setHistoryViewerOpen(value: boolean) { historyViewerOpen = value; } }); - const { - platform: readPlatform, - desktopBoards: readDesktopBoards, - desktopFileName: readDesktopFileName, - handleDesktopOpen, - handleDesktopNewBoard, - handleDesktopSaveAs, - handleDesktopRecentSelect, - currentToolId: readCurrentToolId, - handleToolChange, - handleHistoryClick, - handleHistoryClose, - store, - getViewport, - handleCanvasDoubleClick, - handlePointerLeave, - textEditor: readTextEditor, - getTextEditorLayout, - handleTextEditorInput, - handleTextEditorKeyDown, - handleTextEditorBlur, - cursorStore, - persistenceStatusStore: readPersistenceStatusStore, - snapStore, - setCanvasRef, - setTextEditorElRef - } = controller; - - let platform = $derived(readPlatform()); - let desktopBoards = $derived(readDesktopBoards()); - let desktopFileName = $derived(readDesktopFileName()); - let currentToolId = $derived(readCurrentToolId()); - let textEditor = $derived(readTextEditor()); - let persistenceStatusStore = $derived(readPersistenceStatusStore()); + let platform = $derived(c.platform()); + let textEditorCurrent = $derived(c.textEditor.current); + let persistenceStatusStore = $derived(c.persistenceStatusStore()); $effect(() => { - setCanvasRef(canvasEl); - return () => setCanvasRef(null); + c.setCanvasRef(canvasEl); + return () => c.setCanvasRef(null); }); $effect(() => { - setTextEditorElRef(textEditorEl); - return () => setTextEditorElRef(null); + c.textEditor.setRef(textEditorEl); + return () => c.textEditor.setRef(null); }); @@ -65,42 +35,55 @@ + fileName: c.desktop.fileName, + recentBoards: c.desktop.boards, + onOpen: c.desktop.handleOpen, + onNew: c.desktop.handleNew, + onSaveAs: () => c.desktop.handleSaveAs(null), + onSelectBoard: c.desktop.handleRecentSelect + }} + onOpenBrowser={c.fileBrowser.handleOpen} />
- {#if textEditor} - {@const layout = getTextEditorLayout()} + ondblclick={c.handleCanvasDoubleClick} + onpointerleave={c.handlePointerLeave}> + {#if textEditorCurrent} + {@const layout = c.textEditor.getLayout()} {#if layout} {/if} {/if}
- - + + + {#if c.fileBrowser.vm} + + {/if} diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts index 16711e1..672f189 100644 --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -35,7 +35,7 @@ const persistenceMocks = vi.hoisted(() => { }; }); -vi.mock("../input", () => { +vi.mock("$lib/input", () => { return { createInputAdapter: vi.fn((config) => { actionHandlers.push(config.onAction); @@ -332,7 +332,13 @@ describe("Canvas history integration", () => { it("wraps pointer actions in SnapshotCommands and enqueues persistence", async () => { render(Canvas); - await new Promise((resolve) => setTimeout(resolve, 0)); + // Wait for onMount to complete and input adapter to be created + await vi.waitFor(() => { + expect(actionHandlers.length).toBeGreaterThan(0); + }); + await vi.waitFor(() => { + expect(persistenceMocks.createPersistenceManager).toHaveBeenCalled(); + }); const handler = actionHandlers.at(-1); expect(handler).toBeTypeOf("function"); @@ -368,6 +374,6 @@ describe("Canvas history integration", () => { const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; expect(stores.at(-1)?.commands).toHaveLength(1); expect(stores.at(-1)?.commands[0].kind).toBe("doc"); - expect(persistenceMocks.state.instance?.sink.enqueueDocPatch).toHaveBeenCalledTimes(1); + expect(sinkEnqueueSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/lib/tests/Canvas.keyboard.test.ts b/apps/web/src/lib/tests/Canvas.keyboard.test.ts index e8a6cb4..176f66f 100644 --- a/apps/web/src/lib/tests/Canvas.keyboard.test.ts +++ b/apps/web/src/lib/tests/Canvas.keyboard.test.ts @@ -1,11 +1,51 @@ -import type { Action, Command } from "inkfinite-core"; +import type { Action, Command, Store } from "inkfinite-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "vitest-browser-svelte"; const actionHandlers: Array<(action: Action) => void> = []; -const coreMocks = vi.hoisted(() => ({ storeInstances: [] as unknown[], executeCommandSpy: vi.fn() })); +const coreMocks = vi.hoisted(() => ({ storeInstances: [] as Store[], executeCommandSpy: vi.fn() })); + +async function selectShapeAt(handler: (action: Action) => void, position: { x: number; y: number }) { + const timestamp = Date.now(); + handler({ + type: "pointer-down", + button: 0, + buttons: { left: true, middle: false, right: false }, + world: position, + screen: position, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp, + }); + handler({ + type: "pointer-up", + button: 0, + buttons: { left: false, middle: false, right: false }, + world: position, + screen: position, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: timestamp + 16, + }); + await Promise.resolve(); +} + +async function selectDefaultShape(handler: (action: Action) => void) { + await selectShapeAt(handler, { x: 110, y: 110 }); +} + +async function selectSecondaryShape(handler: (action: Action) => void) { + await selectShapeAt(handler, { x: 210, y: 210 }); +} + +async function waitForDocumentReady() { + await vi.waitFor(() => { + const store = coreMocks.storeInstances.at(-1); + expect(store).toBeTruthy(); + const pages = Object.keys(store!.getState().doc.pages); + expect(pages.length).toBeGreaterThan(0); + }); +} -vi.mock("../input", () => { +vi.mock("$lib/input", () => { return { createInputAdapter: vi.fn((config) => { actionHandlers.push(config.onAction); @@ -50,6 +90,11 @@ vi.mock("inkfinite-core", async () => { const { executeCommandSpy } = coreMocks; class MockStore extends actual.Store { + constructor(...args: ConstructorParameters) { + super(...args); + coreMocks.storeInstances.push(this as unknown as Store); + } + executeCommand(command: unknown) { executeCommandSpy(command); return super.executeCommand(command as Command); @@ -72,7 +117,7 @@ vi.mock("inkfinite-core", async () => { renameBoard: async () => {}, deleteBoard: async () => {}, loadDoc: async () => ({ - pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] } }, + pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1", "shape:2"] } }, shapes: { "shape:1": { id: "shape:1", @@ -83,6 +128,15 @@ vi.mock("inkfinite-core", async () => { rot: 0, props: { w: 50, h: 50, fill: "#ff0000", stroke: "#000000", radius: 0 }, }, + "shape:2": { + id: "shape:2", + type: "ellipse", + pageId: "page:1", + x: 200, + y: 200, + rot: 0, + props: { w: 40, h: 40, fill: "#00ff00", stroke: "#000000" }, + }, }, bindings: {}, order: { pageIds: ["page:1"] }, @@ -110,7 +164,6 @@ describe("Canvas keyboard shortcuts", () => { it("should handle space key for panning mode", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); coreMocks.executeCommandSpy.mockClear(); const handler = actionHandlers[0]; @@ -140,10 +193,11 @@ describe("Canvas keyboard shortcuts", () => { it("should nudge selected shapes with arrow keys", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; + await selectDefaultShape(handler); - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); coreMocks.executeCommandSpy.mockClear(); handler({ @@ -165,9 +219,10 @@ describe("Canvas keyboard shortcuts", () => { it("should nudge by 10px with shift modifier", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); + await selectDefaultShape(handler); coreMocks.executeCommandSpy.mockClear(); handler({ @@ -189,9 +244,10 @@ describe("Canvas keyboard shortcuts", () => { it("should duplicate selected shapes with Cmd/Ctrl+D", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); + await selectDefaultShape(handler); coreMocks.executeCommandSpy.mockClear(); const isMac = navigator.userAgent.toUpperCase().includes("MAC"); @@ -214,9 +270,10 @@ describe("Canvas keyboard shortcuts", () => { it("should bring shapes forward with Cmd/Ctrl+]", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); + await selectDefaultShape(handler); coreMocks.executeCommandSpy.mockClear(); const isMac = navigator.userAgent.toUpperCase().includes("MAC"); @@ -239,9 +296,10 @@ describe("Canvas keyboard shortcuts", () => { it("should send shapes backward with Cmd/Ctrl+[", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); + await selectSecondaryShape(handler); coreMocks.executeCommandSpy.mockClear(); const isMac = navigator.userAgent.toUpperCase().includes("MAC"); @@ -264,9 +322,10 @@ describe("Canvas keyboard shortcuts", () => { it("should not process tool actions while space is held", async () => { render(Canvas); await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); + await waitForDocumentReady(); const handler = actionHandlers[0]; - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); + await selectDefaultShape(handler); coreMocks.executeCommandSpy.mockClear(); handler({ diff --git a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts new file mode 100644 index 0000000..29f0cf7 --- /dev/null +++ b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts @@ -0,0 +1,305 @@ +import FileBrowser from "$lib/filebrowser/FileBrowser.svelte"; +import type { BoardMeta, FileBrowserViewModel } from "inkfinite-core"; +import { FileBrowserVM } from "inkfinite-core"; +import { describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import { page } from "vitest/browser"; + +const mockRepo = { + listBoards: vi.fn(), + createBoard: vi.fn(), + openBoard: vi.fn(), + renameBoard: vi.fn(), + deleteBoard: vi.fn(), +}; + +function createMockBoards(): BoardMeta[] { + return [{ id: "board-1", name: "Board 1", createdAt: 1000, updatedAt: 2000 }, { + id: "board-2", + name: "Board 2", + createdAt: 1500, + updatedAt: 2500, + }, { id: "board-3", name: "Test Board", createdAt: 2000, updatedAt: 3000 }]; +} + +function createMockVM(boards: BoardMeta[]): FileBrowserViewModel { + return FileBrowserVM.create({ repo: mockRepo, boards }); +} + +describe("FileBrowser", () => { + describe("boards list", () => { + it("should render boards when provided", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + await expect.element(page.getByText("Board 1")).toBeVisible(); + await expect.element(page.getByText("Board 2")).toBeVisible(); + await expect.element(page.getByText("Test Board")).toBeVisible(); + }); + + it("should show empty state when no boards", async () => { + const vm = createMockVM([]); + + render(FileBrowser, { vm, open: true }); + + await expect.element(page.getByText("No boards yet")).toBeVisible(); + }); + + it("should show filtered empty state when query has no matches", async () => { + const boards = createMockBoards(); + const vm = FileBrowserVM.setQuery(createMockVM(boards), "NonExistent"); + + render(FileBrowser, { vm, open: true }); + + await expect.element(page.getByText("No boards match your search")).toBeVisible(); + }); + }); + + describe("search functionality", () => { + it("should have search input", async () => { + const vm = createMockVM(createMockBoards()); + + render(FileBrowser, { vm, open: true }); + + const searchInput = page.getByPlaceholder("Search boards..."); + await expect.element(searchInput).toBeInTheDocument(); + }); + + it("should update query on input", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onUpdate = vi.fn(); + + render(FileBrowser, { vm, open: true, onUpdate }); + + const searchInput = page.getByPlaceholder("Search boards..."); + await searchInput.fill("Test"); + + await expect.poll(() => searchInput.query()).toHaveValue("Test"); + }); + }); + + describe("board actions", () => { + it("should show create board button", async () => { + const vm = createMockVM(createMockBoards()); + + render(FileBrowser, { vm, open: true }); + + const createButton = page.getByRole("button", { name: /create new board/i }); + await expect.element(createButton).toBeVisible(); + }); + + it("should show create form when new button is clicked", async () => { + const vm = createMockVM(createMockBoards()); + + render(FileBrowser, { vm, open: true }); + + const newButton = page.getByRole("button", { name: /create new board/i }); + await newButton.click(); + + await expect.element(page.getByPlaceholder("Board name")).toBeVisible(); + await expect.element(page.getByRole("button", { name: /^create$/i })).toBeVisible(); + }); + + it("should have inspect buttons for each board", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + await expect.poll(() => inspectButtons.all()).toHaveLength(3); + }); + + it("should have rename buttons for each board", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + const renameButtons = page.getByLabelText(/rename board/i); + await expect.poll(() => renameButtons.all()).toHaveLength(3); + }); + + it("should have delete buttons for each board", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + const deleteButtons = page.getByLabelText(/delete board/i); + await expect.poll(() => deleteButtons.all()).toHaveLength(3); + }); + }); + + describe("inspector drawer", () => { + it("should not show inspector initially", async () => { + const vm = createMockVM(createMockBoards()); + + render(FileBrowser, { vm, open: true }); + + await expect.poll(() => document.querySelector(".inspector__title")).toBeNull(); + }); + + it("should show inspector when inspect button is clicked", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockResolvedValue({ + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, + schema: { declaredVersion: 1, installedVersion: 1 }, + migrations: [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }], + pendingMigrations: [], + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText("Board Inspector")).toBeVisible(); + }); + + it("should display board statistics in inspector", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockResolvedValue({ + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, + schema: { declaredVersion: 1, installedVersion: 1 }, + migrations: [], + pendingMigrations: [], + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText("Statistics")).toBeVisible(); + + await expect.element(page.getByText("Pages:")).toBeVisible(); + await expect.element(page.getByText("Shapes:")).toBeVisible(); + await expect.element(page.getByText("Bindings:")).toBeVisible(); + }); + + it("should display schema information in inspector", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockResolvedValue({ + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, + schema: { declaredVersion: 1, installedVersion: 1 }, + migrations: [], + pendingMigrations: [], + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText("Schema")).toBeVisible(); + await expect.element(page.getByText("Declared Version:")).toBeVisible(); + await expect.element(page.getByText("Installed Version:")).toBeVisible(); + }); + + it("should display migrations in inspector", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockResolvedValue({ + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, + schema: { declaredVersion: 1, installedVersion: 1 }, + migrations: [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }], + pendingMigrations: [], + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText("Migrations")).toBeVisible(); + await expect.element(page.getByText("MIG-0001")).toBeVisible(); + await expect.element(page.getByText("MIG-0002")).toBeVisible(); + }); + + it("should show pending migrations warning when present", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockResolvedValue({ + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, + schema: { declaredVersion: 1, installedVersion: 1 }, + migrations: [{ id: "MIG-0001", appliedAt: 1000 }], + pendingMigrations: ["MIG-0002", "MIG-0003"], + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText(/Pending Migrations:/i)).toBeVisible(); + await expect.element(page.getByText("MIG-0002")).toBeVisible(); + await expect.element(page.getByText("MIG-0003")).toBeVisible(); + }); + + it("should show error when inspector data fetch fails", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockRejectedValue(new Error("Failed to fetch")); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText("Failed to fetch")).toBeVisible(); + }); + }); + + describe("callbacks", () => { + it("should call onUpdate when search changes", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onUpdate = vi.fn(); + + render(FileBrowser, { vm, open: true, onUpdate }); + + const searchInput = page.getByPlaceholder("Search boards..."); + await searchInput.fill("Test"); + + const input = document.querySelector("[placeholder=\"Search boards...\"]") as HTMLInputElement; + input?.dispatchEvent(new Event("change", { bubbles: true })); + + await expect.poll(() => onUpdate).toHaveBeenCalled(); + }); + + it("should call onClose when board is opened", async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onClose = vi.fn(); + + mockRepo.openBoard.mockResolvedValue(undefined); + + render(FileBrowser, { vm, open: true, onClose }); + + const boardName = page.getByText("Board 1"); + await boardName.click(); + + await expect.poll(() => mockRepo.openBoard).toHaveBeenCalledWith("board-1"); + await expect.poll(() => onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 692475e..adffb78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from "./model"; export * from "./persist/DocRepo"; export * from "./persistence/db"; export * from "./persistence/desktop"; +export * from "./persistence/stats"; export * from "./persistence/web"; export * from "./reactivity"; export * from "./tools"; diff --git a/packages/core/src/persistence/db.ts b/packages/core/src/persistence/db.ts index 2545145..7814886 100644 --- a/packages/core/src/persistence/db.ts +++ b/packages/core/src/persistence/db.ts @@ -85,6 +85,11 @@ const MIGRATIONS: Migration[] = [{ }, }]; +/** + * Known migration IDs for tracking pending migrations in the inspector. + */ +export const KNOWN_MIGRATION_IDS = MIGRATIONS.map((m) => m.id); + /** * Run pending logical migrations during schema upgrades */ diff --git a/packages/core/src/persistence/stats.ts b/packages/core/src/persistence/stats.ts new file mode 100644 index 0000000..5718149 --- /dev/null +++ b/packages/core/src/persistence/stats.ts @@ -0,0 +1,61 @@ +import type { Timestamp } from "../persist/DocRepo"; + +export type BoardStats = { + pageCount: number; + shapeCount: number; + bindingCount: number; + docSizeBytes: number; + lastUpdated: Timestamp; +}; + +export type SchemaInfo = { declaredVersion: number; installedVersion: number }; + +export type MigrationInfo = { id: string; appliedAt: Timestamp }; + +export type BoardInspectorData = { + stats: BoardStats; + schema: SchemaInfo; + migrations: MigrationInfo[]; + pendingMigrations: string[]; +}; + +/** + * Calculate board statistics from row counts and doc size. + */ +export const BoardStatsOps = { + create( + options: { + pageCount: number; + shapeCount: number; + bindingCount: number; + docSizeBytes: number; + lastUpdated: Timestamp; + }, + ): BoardStats { + return { + pageCount: options.pageCount, + shapeCount: options.shapeCount, + bindingCount: options.bindingCount, + docSizeBytes: options.docSizeBytes, + lastUpdated: options.lastUpdated, + }; + }, + + /** + * Format doc size in human-readable format (e.g., "1.2 KB", "3.4 MB") + */ + formatDocSize(bytes: number): string { + if (bytes === 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }, +}; + +/** + * Determine pending migrations by comparing known migration list with applied migrations. + */ +export function getPendingMigrations(knownMigrationIds: string[], appliedMigrations: MigrationInfo[]): string[] { + const appliedIds = new Set(appliedMigrations.map((m) => m.id)); + return knownMigrationIds.filter((id) => !appliedIds.has(id)); +} diff --git a/packages/core/src/persistence/web.ts b/packages/core/src/persistence/web.ts index aa31ed4..caa58db 100644 --- a/packages/core/src/persistence/web.ts +++ b/packages/core/src/persistence/web.ts @@ -10,6 +10,8 @@ import { ShapeRecord as ShapeOps, } from "../model"; import type { BoardMeta, DocRepo, Timestamp } from "../persist/DocRepo"; +import type { BoardInspectorData, BoardStats, MigrationInfo, SchemaInfo } from "./stats"; +import { BoardStatsOps, getPendingMigrations } from "./stats"; export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; @@ -467,3 +469,70 @@ function isPatchEmpty(patch: DocPatch): boolean { return !(hasUpserts || hasDeletes || hasOrder); } + +/** + * Fetch board statistics for a given board. + */ +export async function getBoardStats(database: DexieLike, boardId: string): Promise { + const pages = database.table("pages"); + const shapes = database.table("shapes"); + const bindings = database.table("bindings"); + const boards = database.table("boards"); + + const [pageCount, shapeCount, bindingCount, board] = await Promise.all([ + pages.where("boardId").equals(boardId).count(), + shapes.where("boardId").equals(boardId).count(), + bindings.where("boardId").equals(boardId).count(), + boards.get(boardId), + ]); + + const allRows = await Promise.all([ + pages.where("boardId").equals(boardId).toArray(), + shapes.where("boardId").equals(boardId).toArray(), + bindings.where("boardId").equals(boardId).toArray(), + ]); + + const docSizeBytes = JSON.stringify({ pages: allRows[0], shapes: allRows[1], bindings: allRows[2] }).length; + + return BoardStatsOps.create({ + pageCount, + shapeCount, + bindingCount, + docSizeBytes, + lastUpdated: board?.updatedAt ?? 0, + }); +} + +/** + * Fetch schema information from the database. + */ +export async function getSchemaInfo(database: Dexie): Promise { + return { declaredVersion: database.verno, installedVersion: database.verno }; +} + +/** + * Fetch applied migrations from the migrations table. + */ +export async function getAppliedMigrations(database: DexieLike): Promise { + const migrations = database.table("migrations"); + return migrations.orderBy("appliedAt").toArray(); +} + +/** + * Fetch complete inspector data for a board including stats, schema, and migrations. + */ +export async function getBoardInspectorData( + database: Dexie, + boardId: string, + knownMigrationIds: string[], +): Promise { + const [stats, schema, migrations] = await Promise.all([ + getBoardStats(database, boardId), + getSchemaInfo(database), + getAppliedMigrations(database), + ]); + + const pendingMigrations = getPendingMigrations(knownMigrationIds, migrations); + + return { stats, schema, migrations, pendingMigrations }; +} diff --git a/packages/core/tests/stats.test.ts b/packages/core/tests/stats.test.ts new file mode 100644 index 0000000..1d56e9c --- /dev/null +++ b/packages/core/tests/stats.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { MigrationInfo } from "../src/persistence/stats"; +import { BoardStatsOps, getPendingMigrations } from "../src/persistence/stats"; + +describe("BoardStatsOps", () => { + describe("create", () => { + it("creates board stats with provided values", () => { + const stats = BoardStatsOps.create({ + pageCount: 5, + shapeCount: 20, + bindingCount: 3, + docSizeBytes: 1024, + lastUpdated: 1234567890, + }); + + expect(stats).toMatchObject({ + pageCount: 5, + shapeCount: 20, + bindingCount: 3, + docSizeBytes: 1024, + lastUpdated: 1234567890, + }); + }); + + it("handles zero values", () => { + const stats = BoardStatsOps.create({ + pageCount: 0, + shapeCount: 0, + bindingCount: 0, + docSizeBytes: 0, + lastUpdated: 0, + }); + + expect(stats.pageCount).toBe(0); + expect(stats.shapeCount).toBe(0); + expect(stats.bindingCount).toBe(0); + expect(stats.docSizeBytes).toBe(0); + }); + }); + + describe("formatDocSize", () => { + it("formats bytes correctly", () => { + expect(BoardStatsOps.formatDocSize(0)).toBe("0 B"); + expect(BoardStatsOps.formatDocSize(100)).toBe("100 B"); + expect(BoardStatsOps.formatDocSize(1023)).toBe("1023 B"); + }); + + it("formats kilobytes correctly", () => { + expect(BoardStatsOps.formatDocSize(1024)).toBe("1.0 KB"); + expect(BoardStatsOps.formatDocSize(1536)).toBe("1.5 KB"); + expect(BoardStatsOps.formatDocSize(10240)).toBe("10.0 KB"); + }); + + it("formats megabytes correctly", () => { + expect(BoardStatsOps.formatDocSize(1024 * 1024)).toBe("1.0 MB"); + expect(BoardStatsOps.formatDocSize(1.5 * 1024 * 1024)).toBe("1.5 MB"); + expect(BoardStatsOps.formatDocSize(10 * 1024 * 1024)).toBe("10.0 MB"); + }); + + it("rounds to one decimal place", () => { + expect(BoardStatsOps.formatDocSize(1234)).toBe("1.2 KB"); + expect(BoardStatsOps.formatDocSize(1567)).toBe("1.5 KB"); + expect(BoardStatsOps.formatDocSize(1234567)).toBe("1.2 MB"); + }); + }); +}); + +describe("getPendingMigrations", () => { + it("returns empty array when all migrations are applied", () => { + const knownIds = ["MIG-0001", "MIG-0002"]; + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }]; + + const pending = getPendingMigrations(knownIds, applied); + expect(pending).toEqual([]); + }); + + it("returns pending migrations", () => { + const knownIds = ["MIG-0001", "MIG-0002", "MIG-0003"]; + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }]; + + const pending = getPendingMigrations(knownIds, applied); + expect(pending).toEqual(["MIG-0002", "MIG-0003"]); + }); + + it("handles no applied migrations", () => { + const knownIds = ["MIG-0001", "MIG-0002"]; + const applied: MigrationInfo[] = []; + + const pending = getPendingMigrations(knownIds, applied); + expect(pending).toEqual(["MIG-0001", "MIG-0002"]); + }); + + it("ignores applied migrations not in known list", () => { + const knownIds = ["MIG-0001", "MIG-0002"]; + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0003", appliedAt: 2000 }]; + + const pending = getPendingMigrations(knownIds, applied); + expect(pending).toEqual(["MIG-0002"]); + }); + + it("preserves order of known migrations", () => { + const knownIds = ["MIG-0001", "MIG-0002", "MIG-0003", "MIG-0004"]; + const applied: MigrationInfo[] = [{ id: "MIG-0002", appliedAt: 2000 }, { id: "MIG-0004", appliedAt: 4000 }]; + + const pending = getPendingMigrations(knownIds, applied); + expect(pending).toEqual(["MIG-0001", "MIG-0003"]); + }); +}); -- 2.51.2