diff --git a/ROADMAP.md b/ROADMAP.md index 746bd8e..b5bfba8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -193,10 +193,11 @@ board-management interaction, with file timestamps supplied by desktop storage when available. The browser is responsive and accessible on narrow viewports and coarse -pointers. The next pass should add duplication, surface board-action failures, -and protect switches when pending changes cannot be flushed. User-facing board -details should stay easy to scan while file, schema, and document diagnostics -remain available when needed. +pointers. Board actions duplicate boards through each platform's repository, +show busy and failure states in the browser, flush pending edits before a +switch, and recover the active canvas when its board is deleted. The inspector +leads with board name, dates, and location, then shows storage, schema, +document, and record diagnostics. ### Editor polish diff --git a/TODO.md b/TODO.md index 777be6a..dd7437b 100644 --- a/TODO.md +++ b/TODO.md @@ -183,14 +183,14 @@ the canvas editor. ### Board actions -- [ ] Add board duplication through the shared TypeScript repository interface +- [x] Add board duplication through the shared TypeScript repository interface and the existing Rust desktop file/session services -- [ ] Surface TypeScript UI busy and failure states instead of logging +- [x] Surface TypeScript UI busy and failure states instead of logging board-action errors to the console -- [ ] Protect board switches when pending editor changes cannot be flushed -- [ ] Make the board inspector useful to users while retaining file, schema, and +- [x] Protect board switches when pending editor changes cannot be flushed +- [x] Make the board inspector useful to users while retaining file, schema, and document diagnostics for maintainers -- [ ] Add end-to-end tests for the existing board actions plus duplication, +- [x] Add end-to-end tests for the existing board actions plus duplication, switching, workspace selection, and persistence across reloads ## Editor polish diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1d74364..8abd35d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ pub fn run() { session::redo, session::save, session::save_as, + session::duplicate_document, session::save_draft_as, session::query, session::validate, diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index 4d2f703..74da05a 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -384,6 +384,23 @@ pub fn save_draft_as( Ok(saved) } +/// Creates an independent document from the current session and opens it. +#[tauri::command] +pub fn duplicate_document( + state: State<'_, DesktopState>, session_id: String, path: DocumentPath, document_id: String, actor_id: String, + expected_heads: Vec, +) -> Result { + lock_service(&state)? + .duplicate( + &SessionId(session_id), + path.0, + DocumentId::new(document_id), + ActorId::new(actor_id), + &expected_heads, + ) + .map_err(to_protocol_error) +} + /// Queries records through the shared deterministic query implementation. #[tauri::command] pub fn query(state: State<'_, DesktopState>, session_id: String, query: Query) -> Result { diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index d214063..e41b9db 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -322,6 +322,26 @@ function createFakeSessionApi() { }; }, + async duplicateDocument(args: Parameters[0]): Promise { + const source = sessions.get(args.session_id); + if (!source) throw new Error('Missing fake session'); + const snapshot = structuredClone(source.status.snapshot); + snapshot.document_id = args.document_id; + const sessionId = `session:${++sessionNumber}`; + const status: SessionStatus = { + ...source.status, + session_id: sessionId, + path: args.path, + actor_id: args.actor_id, + snapshot, + dirty: false + }; + sessions.delete(args.session_id); + sessions.set(sessionId, { status, undo: [], redo: [] }); + files.set(args.path, structuredClone(snapshot)); + return { session_id: sessionId, status: statusFor(sessionId, sessions.get(sessionId)!) }; + }, + async saveDraftAs(args: Parameters[0]): Promise { const session = sessions.get(args.session_id); if (!session) throw new Error('Missing fake session'); @@ -519,6 +539,19 @@ describe('Rust-backed desktop session repository', () => { expect(reopened.pages[pageId].name).toBe('Renamed'); }); + it('duplicates the active board through the Rust session service', async () => { + fileOps.setSavePath('/tmp/source.inkfinite'); + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + const sourceId = await repo.createBoard('Source'); + fileOps.setSavePath('/tmp/source-copy.inkfinite'); + + const duplicateId = await repo.duplicateBoard(sourceId); + + expect(duplicateId).not.toBe(sourceId); + expect(repo.getCurrentFile()?.path).toBe('/tmp/source-copy.inkfinite'); + expect(repo.getSessionStatus()?.snapshot.document_id).toBe(duplicateId); + }); + it('routes layer changes through the Rust reconciliation command', async () => { const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); const opened = await repo.openDraft(); @@ -593,10 +626,7 @@ describe('Rust-backed desktop session repository', () => { expect(boards.map((board) => board.name)).toEqual(['alpha']); expect(boards.every((board) => board.id.startsWith('path:'))).toBe(true); - expect(boards[0]).toMatchObject({ - updatedAt: 1234, - storage: { kind: 'workspace', location: '/workspace' } - }); + expect(boards[0]).toMatchObject({ updatedAt: 1234, storage: { kind: 'workspace', location: '/workspace' } }); }); it('persists the app-managed draft across renderer sessions without adding it to recent files', async () => { diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 2d0fd2b..9f7662f 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -150,6 +150,13 @@ export interface SessionApi { redo(args: { session_id: string; actor_id: string }): Promise; save(args: { session_id: string; expected_heads: ChangeHash[] }): Promise; saveAs(args: { session_id: string; path: string; expected_heads: ChangeHash[] }): Promise; + duplicateDocument(args: { + session_id: string; + path: string; + document_id: string; + actor_id: string; + expected_heads: ChangeHash[]; + }): Promise; saveDraftAs(args: { session_id: string; path: string; expected_heads: ChangeHash[] }): Promise; query(args: { session_id: string; query: Query }): Promise; validate(args: { session_id: string }): Promise; @@ -221,6 +228,14 @@ function createSessionApi(): SessionApi { path: args.path, expectedHeads: args.expected_heads }), + duplicateDocument: (args) => + invokeSession('duplicate_document', { + sessionId: args.session_id, + path: args.path, + documentId: args.document_id, + actorId: args.actor_id, + expectedHeads: args.expected_heads + }), saveDraftAs: (args) => invokeSession('save_draft_as', { sessionId: args.session_id, @@ -568,6 +583,34 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return opened.status.snapshot.document_id; } + async function duplicateBoard(boardId: string, name?: string): Promise { + await ensureBoardLoaded(boardId); + if (!currentStatus || !currentBoard || !currentFile) throw new Error('No board loaded'); + + if (currentStatus.dirty) await saveCurrentSession(); + if (!currentStatus || !currentBoard || !currentFile) throw new Error('No board loaded'); + + const boardName = name?.trim() || `Copy of ${currentBoard.name}`; + const workspace = await fileOps.getWorkspaceDir(); + const path = workspace + ? joinPath(workspace, `${safeFileStem(boardName)}.inkfinite`) + : await fileOps.showSaveDialog(`${safeFileStem(boardName)}.inkfinite`); + if (!path) throw new Error('Save cancelled'); + + const sourceStatus = currentStatus; + const opened = await api.duplicateDocument({ + session_id: sourceStatus.session_id, + path, + document_id: createId('board'), + actor_id: ACTOR_ID, + expected_heads: sourceStatus.snapshot.heads + }); + setCurrentState(opened.status, boardName); + currentBoard = { ...currentBoard!, createdAt: Date.now(), updatedAt: Date.now() }; + if (!workspace && currentFile) await fileOps.addRecentFile(currentFile); + return opened.status.snapshot.document_id; + } + async function importSvg(path?: string): Promise { if (!currentStatus || !currentDoc) throw new Error('No board loaded'); const selectedPath = path ?? (await fileOps.showSvgDialog()); @@ -877,6 +920,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: isDraft: () => currentIsDraft, listBoards, createBoard, + duplicateBoard, openBoard, renameBoard, deleteBoard, diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index 735080a..594e6a3 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -94,6 +94,9 @@ describe('desktop workspace adapter', () => { saveAs: async () => { throw new Error('not used'); }, + duplicateDocument: async () => { + throw new Error('not used'); + }, saveDraftAs: async () => { throw new Error('not used'); }, diff --git a/apps/web/e2e/board-actions.spec.ts b/apps/web/e2e/board-actions.spec.ts new file mode 100644 index 0000000..07e4b3e --- /dev/null +++ b/apps/web/e2e/board-actions.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from './fixtures/editor'; + +async function openBoards(editor: { page: import('@playwright/test').Page }) { + await editor.page.getByRole('button', { name: 'Browse boards' }).click(); + return editor.page.getByRole('dialog', { name: 'Boards' }); +} + +test('creates, renames, duplicates, and switches boards', async ({ editor, page }) => { + await editor.open(); + let browser = await openBoards(editor); + + await browser.getByRole('button', { name: 'Create new board' }).click(); + await browser.getByRole('textbox', { name: 'New board name' }).fill('Project map'); + await browser.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(browser).not.toBeVisible(); + + browser = await openBoards(editor); + await browser.getByPlaceholder('Search boards...').fill('Project map'); + const projectRow = browser.locator('[data-board-row]').first(); + await expect(projectRow).toContainText('Project map'); + await projectRow.getByLabel('Rename board').click(); + await browser.getByRole('textbox', { name: 'Board name' }).fill('Project map renamed'); + await projectRow.getByRole('button', { name: 'Save', exact: true }).click(); + + await expect(browser.getByText('Project map renamed')).toBeVisible(); + await projectRow.getByLabel('Duplicate board').click(); + await expect(browser).not.toBeVisible(); + + browser = await openBoards(editor); + await browser.getByPlaceholder('Search boards...').fill('Copy of Project map renamed'); + const duplicateRow = browser.locator('[data-board-row]').first(); + await expect(duplicateRow).toContainText('Copy of Project map renamed'); + await duplicateRow.getByRole('button', { name: /Open Copy of Project map renamed/ }).click(); + await expect(browser).not.toBeVisible(); + + await page.reload({ waitUntil: 'networkidle' }); + await expect(page.getByRole('button', { name: 'Shapes', exact: true })).toBeVisible(); + browser = await openBoards(editor); + await browser.getByPlaceholder('Search boards...').fill('Copy of Project map renamed'); + await expect(browser.locator('[data-board-row]').first()).toContainText( + 'Copy of Project map renamed' + ); +}); + +test('deletes a board and reports inspector details', async ({ editor, page }) => { + await editor.open(); + let browser = await openBoards(editor); + + await browser.getByRole('button', { name: 'Create new board' }).click(); + await browser.getByRole('textbox', { name: 'New board name' }).fill('Disposable map'); + await browser.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(browser).not.toBeVisible(); + + browser = await openBoards(editor); + await browser.getByPlaceholder('Search boards...').fill('Disposable map'); + const row = browser.locator('[data-board-row]').first(); + await row.getByLabel('Inspect board').click(); + await expect(page.getByRole('heading', { name: 'Board Inspector' })).toBeVisible(); + await expect(page.getByText('Board details')).toBeVisible(); + await expect(page.getByText('Statistics')).toBeVisible(); + await page.getByLabel('Close inspector').click(); + + page.once('dialog', (dialog) => dialog.accept()); + await row.getByLabel('Delete board').click(); + await expect(browser.getByText('Disposable map')).not.toBeVisible(); +}); diff --git a/apps/web/src/dexie-repository.test.ts b/apps/web/src/dexie-repository.test.ts index 74690af..ad7fc5a 100644 --- a/apps/web/src/dexie-repository.test.ts +++ b/apps/web/src/dexie-repository.test.ts @@ -57,6 +57,33 @@ describe('DocRepo (Dexie)', () => { expect(boardRow?.name).toBe('Renamed'); }); + it('duplicates a board into an independent board with a fresh name and id', async () => { + const database = createTestDb(); + const repo = createDexieDocRepo(database); + const sourceId = await repo.createBoard('Source'); + + const page = PageRecord.create('Canvas'); + const rect = ShapeRecord.createRect(page.id, 0, 0, { + w: 20, + h: 20, + fill: '#000', + stroke: '#fff', + radius: 0 + }); + page.shapeIds.push(rect.id); + const doc = DocumentOps.create(); + doc.pages[page.id] = page; + doc.shapes[rect.id] = rect; + await repo.applyDocPatch(sourceId, diffDoc(DocumentOps.create(), doc)); + + const duplicateId = await repo.duplicateBoard(sourceId); + expect(duplicateId).not.toBe(sourceId); + expect((await repo.listBoards()).map((board) => board.name)).toEqual( + expect.arrayContaining(['Source', 'Copy of Source']) + ); + expect((await repo.loadDoc(duplicateId)).shapes[rect.id]).toEqual(rect); + }); + it('round-trips docs via applyDocPatch + loadDoc', async () => { const database = createTestDb(); const repo = createDexieDocRepo(database); diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts index bcefa49..8cd6280 100644 --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -120,8 +120,28 @@ export function createDexieDocRepo( return boardId; } + async function duplicateBoard(boardId: string, name?: string): Promise { + const source = await exportBoard(boardId); + const duplicateId = createId('board'); + const timestamp = now(); + const duplicateName = name?.trim() || `Copy of ${source.board.name}`; + return importBoard({ + ...source, + board: { + ...source.board, + id: duplicateId, + name: duplicateName, + createdAt: timestamp, + updatedAt: timestamp + } + }); + } + async function renameBoard(boardId: string, name: string): Promise { - await boards().update(boardId, { name, updatedAt: now() }); + await boards().update(boardId, { + name: name.trim() || DEFAULT_BOARD_NAME, + updatedAt: now() + }); } async function deleteBoard(boardId: string): Promise { @@ -417,6 +437,7 @@ export function createDexieDocRepo( return { listBoards, createBoard, + duplicateBoard, openBoard, renameBoard, deleteBoard, @@ -653,30 +674,49 @@ export async function getBoardStats(database: DexieLike, boardId: string): Promi const shapes = database.table('shapes'); const bindings = database.table('bindings'); const boards = database.table('boards'); + const canonical = database.table('canonical'); + const meta = database.table('meta'); + + const [board, canonicalRow] = await Promise.all([boards.get(boardId), canonical.get(boardId)]); + if (canonicalRow) { + const document = canonicalRow.snapshot.document; + return BoardStatsOps.create({ + pageCount: Object.keys(document.pages).length, + shapeCount: Object.keys(document.shapes).length, + bindingCount: Object.keys(document.bindings).length, + layerCount: Object.keys(document.layers).length, + assetCount: Object.keys(document.assets).length, + docSizeBytes: canonicalRow.bytes.byteLength, + lastUpdated: board?.updatedAt ?? canonicalRow.updatedAt + }); + } - 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([ + const [pageRows, shapeRows, bindingRows, layersRow, assetsRow] = await Promise.all([ pages.where('boardId').equals(boardId).toArray(), shapes.where('boardId').equals(boardId).toArray(), - bindings.where('boardId').equals(boardId).toArray() + bindings.where('boardId').equals(boardId).toArray(), + meta.get(layersKey(boardId)), + meta.get(assetsKey(boardId)) ]); const docSizeBytes = JSON.stringify({ - pages: allRows[0], - shapes: allRows[1], - bindings: allRows[2] + pages: pageRows, + shapes: shapeRows, + bindings: bindingRows, + layers: layersRow?.value, + assets: assetsRow?.value }).length; return BoardStatsOps.create({ - pageCount, - shapeCount, - bindingCount, + pageCount: pageRows.length, + shapeCount: shapeRows.length, + bindingCount: bindingRows.length, + layerCount: layersRow?.value + ? Object.keys(layersRow.value as Record).length + : undefined, + assetCount: assetsRow?.value + ? Object.keys(assetsRow.value as Record).length + : undefined, docSizeBytes, lastUpdated: board?.updatedAt ?? 0 }); @@ -694,9 +734,21 @@ export async function getBoardInspectorData( database: Dexie, boardId: string ): Promise { - const [stats, schema] = await Promise.all([ + const [stats, schema, canonicalRow] = await Promise.all([ getBoardStats(database, boardId), - getSchemaInfo(database) + getSchemaInfo(database), + database.table('canonical').get(boardId) ]); - return { storageType: 'IndexedDB (Dexie)', stats, schema }; + return { + storageType: 'IndexedDB (Dexie)', + stats, + schema, + document: canonicalRow + ? { + documentId: canonicalRow.snapshot.document_id, + formatVersion: canonicalRow.snapshot.format_version, + canonical: true + } + : { canonical: false } + }; } diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts index cd5018b..872c1af 100644 --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -74,6 +74,7 @@ vi.mock('$lib/persistence/repository', () => ({ { id: 'board:1', name: 'Board 1', createdAt: 0, updatedAt: 0 } ]), createBoard: vi.fn(async () => 'board:new'), + duplicateBoard: vi.fn(async () => 'board:copy'), openBoard: vi.fn(async () => {}), renameBoard: vi.fn(), deleteBoard: vi.fn(), diff --git a/apps/web/src/lib/tests/Canvas.keyboard.test.ts b/apps/web/src/lib/tests/Canvas.keyboard.test.ts index 419c888..7606176 100644 --- a/apps/web/src/lib/tests/Canvas.keyboard.test.ts +++ b/apps/web/src/lib/tests/Canvas.keyboard.test.ts @@ -102,6 +102,7 @@ vi.mock('$lib/persistence/repository', () => ({ { id: 'board-1', name: 'Test Board', createdAt: 0, updatedAt: 0 } ], createBoard: async () => 'board-1', + duplicateBoard: async () => 'board-copy', openBoard: async () => {}, renameBoard: async () => {}, deleteBoard: async () => {}, diff --git a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts index c2be1a4..45f4cf8 100644 --- a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts +++ b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts @@ -8,6 +8,7 @@ import { page } from 'vitest/browser'; const mockRepo = { listBoards: vi.fn(), createBoard: vi.fn(), + duplicateBoard: vi.fn(), openBoard: vi.fn(), renameBoard: vi.fn(), deleteBoard: vi.fn() diff --git a/apps/web/src/lib/tests/status.test.ts b/apps/web/src/lib/tests/status.test.ts index cd8943d..08e1276 100644 --- a/apps/web/src/lib/tests/status.test.ts +++ b/apps/web/src/lib/tests/status.test.ts @@ -9,6 +9,7 @@ function createMockRepo(): PersistentDocRepo { return { listBoards: vi.fn(async () => []), createBoard: vi.fn(async () => 'board:mock'), + duplicateBoard: vi.fn(async () => 'board:copy'), openBoard: vi.fn(async () => {}), renameBoard: vi.fn(async () => {}), deleteBoard: vi.fn(async () => {}), diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index 71ae08f..19021f9 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -930,6 +930,30 @@ impl SessionService { Ok(SessionSaved { save, status }) } + /// Creates an independent document from the current session and switches to it. + /// + /// The source heads are checked before the snapshot is copied. The source + /// session is closed only after the new file has been created and opened. + /// + /// # Errors + /// + /// Returns a typed session, stale-head, validation, lock, or filesystem + /// error when the source or destination cannot be handled safely. + pub fn duplicate( + &mut self, session_id: &SessionId, path: impl AsRef, document_id: DocumentId, actor_id: ActorId, + expected_heads: &[crate::ChangeHash], + ) -> Result { + let snapshot = { + let session = self.session_mut(session_id)?; + ensure_heads(&mut session.file, expected_heads)?; + session.file.snapshot()? + }; + let file = DocumentFile::create(path, document_id, actor_id, snapshot.document)?; + let opened = self.insert(file)?; + self.close(session_id)?; + Ok(opened) + } + /// Runs a deterministic semantic query against the current snapshot. /// /// # Errors @@ -1729,6 +1753,49 @@ mod tests { remove_test_directory(root); } + #[test] + fn duplicate_creates_an_independent_session_with_current_content() { + let root = test_directory(); + let source_path = root.join("source.inkfinite"); + let duplicate_path = root.join("source-copy.inkfinite"); + let actor = ActorId::from("actor:session-test"); + let mut service = SessionService::new(); + let source = service + .create( + &source_path, + DocumentId::from("document:source"), + actor.clone(), + Some("Source"), + ) + .expect("create source"); + let source_heads = source.status.snapshot.heads.clone(); + + let duplicate = service + .duplicate( + &source.session_id, + &duplicate_path, + DocumentId::from("document:source-copy"), + actor, + &source_heads, + ) + .expect("duplicate source"); + + assert_eq!( + duplicate.status.snapshot.document_id, + DocumentId::from("document:source-copy") + ); + assert_eq!( + duplicate.status.snapshot.document.pages.values().next().unwrap().name, + "Source" + ); + assert!(matches!( + service.status(&source.session_id), + Err(SessionError::NotFound(_)) + )); + service.close(&duplicate.session_id).expect("close duplicate"); + remove_test_directory(root); + } + #[test] fn stale_save_is_rejected_without_changing_the_session() { let root = test_directory(); diff --git a/packages/core/src/persistence/repo.ts b/packages/core/src/persistence/repo.ts index 0bc7983..a12147f 100644 --- a/packages/core/src/persistence/repo.ts +++ b/packages/core/src/persistence/repo.ts @@ -26,6 +26,14 @@ export interface DocRepo { */ createBoard(name: string): Promise; + /** + * Duplicate a board and return the new board identifier. + * + * The duplicate receives an independent document identity and uses the + * supplied name when provided. + */ + duplicateBoard(boardId: string, name?: string): Promise; + /** * Load the requested board into the active editing context. */ diff --git a/packages/core/src/persistence/stats.ts b/packages/core/src/persistence/stats.ts index 5a2e4dd..48260c2 100644 --- a/packages/core/src/persistence/stats.ts +++ b/packages/core/src/persistence/stats.ts @@ -1,51 +1,61 @@ -import type { Timestamp } from "./repo"; +import type { Timestamp } from './repo'; export type BoardStats = { - pageCount: number; - shapeCount: number; - bindingCount: number; - docSizeBytes: number; - lastUpdated: Timestamp; + pageCount: number; + shapeCount: number; + bindingCount: number; + /** Optional counts supplied by adapters with layer and asset materialization. */ + layerCount?: number; + assetCount?: number; + docSizeBytes: number; + lastUpdated: Timestamp; }; +/** Document-level details useful when investigating a board's persisted state. */ +export type BoardDocumentDiagnostics = { documentId?: string; formatVersion?: number; canonical: boolean }; + export type SchemaInfo = { declaredVersion: number; installedVersion: number }; export type BoardInspectorData = { - /** Human-readable storage backend supplied by the application adapter. */ + /** Human-readable storage backend supplied by the application adapter. */ storageType: string; stats: BoardStats; schema: SchemaInfo; + /** Optional document identity and representation details from the adapter. */ + document?: BoardDocumentDiagnostics; }; /** * 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, - }; - }, + create(options: { + pageCount: number; + shapeCount: number; + bindingCount: number; + layerCount?: number; + assetCount?: number; + docSizeBytes: number; + lastUpdated: Timestamp; + }): BoardStats { + return { + pageCount: options.pageCount, + shapeCount: options.shapeCount, + bindingCount: options.bindingCount, + ...(options.layerCount !== undefined ? { layerCount: options.layerCount } : {}), + ...(options.assetCount !== undefined ? { assetCount: options.assetCount } : {}), + 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`; - }, + /** + * 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`; + } }; diff --git a/packages/core/src/ui/filebrowser.ts b/packages/core/src/ui/filebrowser.ts index 620f0d6..5dc90fe 100644 --- a/packages/core/src/ui/filebrowser.ts +++ b/packages/core/src/ui/filebrowser.ts @@ -3,6 +3,7 @@ import type { BoardMeta, DocRepo } from '../persistence/repo'; export type FileBrowserActions = { open(boardId: string): Promise; create(name: string): Promise; + duplicate(boardId: string, name?: string): Promise; rename(boardId: string, name: string): Promise; delete(boardId: string): Promise; }; @@ -135,6 +136,9 @@ function createActions(repo: DocRepo): FileBrowserActions { async create(name: string) { return repo.createBoard(name); }, + async duplicate(boardId: string, name?: string) { + return repo.duplicateBoard(boardId, name); + }, async rename(boardId: string, name: string) { await repo.renameBoard(boardId, name); }, diff --git a/packages/core/tests/filebrowser.test.ts b/packages/core/tests/filebrowser.test.ts index 32b949b..1898ea5 100644 --- a/packages/core/tests/filebrowser.test.ts +++ b/packages/core/tests/filebrowser.test.ts @@ -6,6 +6,7 @@ function createRepoMock(): DocRepo { return { listBoards: vi.fn(async () => []), createBoard: vi.fn(async () => 'board:new'), + duplicateBoard: vi.fn(async () => 'board:copy'), openBoard: vi.fn(async () => {}), renameBoard: vi.fn(async () => {}), deleteBoard: vi.fn(async () => {}) @@ -65,6 +66,9 @@ describe('FileBrowserVM', () => { await vm.actions.create('Untitled'); expect(repo.createBoard).toHaveBeenCalledWith('Untitled'); + await vm.actions.duplicate('board:alpha', 'Copy'); + expect(repo.duplicateBoard).toHaveBeenCalledWith('board:alpha', 'Copy'); + await vm.actions.rename('board:alpha', 'Renamed'); expect(repo.renameBoard).toHaveBeenCalledWith('board:alpha', 'Renamed'); diff --git a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts index 512319b..b89c828 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts @@ -49,7 +49,6 @@ export class DesktopFileController { this.boards = boards; return boards; } catch (error) { - console.error('Failed to list boards', error); this.report(error, 'Document error'); this.boards = []; return []; @@ -71,7 +70,6 @@ export class DesktopFileController { if (isUserCancelled(error)) { return; } - console.error('Failed to open board', error); this.report(error, 'Open document failed'); } }; @@ -92,7 +90,6 @@ export class DesktopFileController { if (isUserCancelled(error)) { return; } - console.error('Failed to create board', error); this.report(error, 'Create document failed'); } }; @@ -111,7 +108,6 @@ export class DesktopFileController { if (isUserCancelled(error)) { return; } - console.error('Failed to save board', error); this.report(error, 'Save document failed'); } }; @@ -134,7 +130,6 @@ export class DesktopFileController { this.onLoadDoc(boardId, loaded); await this.refreshBoards(); } catch (error) { - console.error('Failed to load board', error); this.report(error, 'Load document failed'); } }; diff --git a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts index 08bd245..6d3eb78 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts @@ -47,7 +47,6 @@ export class FileBrowserController { this.vm = FileBrowserVM.create({ repo: this.createBrowserRepo(repo), boards }); } } catch (error) { - console.error('Failed to list boards', error); this.onError?.(error, 'Document error'); } }; @@ -74,6 +73,27 @@ export class FileBrowserController { onError?.(error, 'Load document failed'); throw error; } + }, + async duplicateBoard(boardId, name) { + try { + await prepareToSwitch?.(); + const duplicateId = await repo.duplicateBoard(boardId, name); + const doc = await repo.loadDoc(duplicateId); + onLoadDoc?.(duplicateId, doc); + return duplicateId; + } catch (error) { + onError?.(error, 'Duplicate board failed'); + throw error; + } + }, + async deleteBoard(boardId) { + try { + await prepareToSwitch?.(); + await repo.deleteBoard(boardId); + } catch (error) { + onError?.(error, 'Delete board failed'); + throw error; + } } }; } diff --git a/packages/ui/src/lib/editor/filebrowser/FileBrowser.svelte b/packages/ui/src/lib/editor/filebrowser/FileBrowser.svelte index 950fd07..d0cf6d5 100644 --- a/packages/ui/src/lib/editor/filebrowser/FileBrowser.svelte +++ b/packages/ui/src/lib/editor/filebrowser/FileBrowser.svelte @@ -41,6 +41,7 @@ let searchQuery = $derived(vm.query); let inspectorOpen = $state(false); let inspectorData = $state(null); + let inspectorBoard = $state(null); let inspectorLoading = $state(false); let inspectorError = $state(null); @@ -53,6 +54,8 @@ let currentFilePath = $state(null); let workspaceBusy = $state(false); let workspaceError = $state(null); + let actionBusy = $state(null); + let actionError = $state(null); let persistenceSnapshot = $state(null); $effect(() => { @@ -156,37 +159,78 @@ handleClose?.(); } + function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + function beginAction(action: string): boolean { + if (actionBusy) return false; + actionBusy = action; + actionError = null; + return true; + } + + function endAction(action: string) { + if (actionBusy === action) actionBusy = null; + } + + function reportActionError(action: string, error: unknown) { + actionError = `${action}: ${describeError(error)}`; + } + async function handleOpenBoard(boardId: string) { + const board = vm.boards.find((item) => item.id === boardId); + if (!beginAction(`open:${boardId}`)) return; try { await vm.actions.open(boardId); closeBrowser(); } catch (error) { - console.error('Failed to open board:', error); + reportActionError(`Could not open ${board?.name ?? 'board'}`, error); + } finally { + endAction(`open:${boardId}`); } } async function handleCreateBoard() { - if (!newBoardName.trim()) return; + if (!newBoardName.trim() || !beginAction('create')) return; try { const boardId = await vm.actions.create(newBoardName); isCreating = false; newBoardName = ''; onUpdate?.(vm); - await handleOpenBoard(boardId); + await vm.actions.open(boardId); + closeBrowser(); + } catch (error) { + reportActionError('Could not create board', error); + } finally { + endAction('create'); + } + } + + async function handleDuplicateBoard(board: BoardMeta) { + if (!beginAction(`duplicate:${board.id}`)) return; + try { + await vm.actions.duplicate(board.id, `Copy of ${board.name}`); + onUpdate?.(vm); + closeBrowser(); } catch (error) { - console.error('Failed to create board:', error); + reportActionError(`Could not duplicate ${board.name}`, error); + } finally { + endAction(`duplicate:${board.id}`); } } async function handleRenameBoard(boardId: string) { - if (!editingBoardName.trim()) return; + if (!editingBoardName.trim() || !beginAction(`rename:${boardId}`)) return; try { await vm.actions.rename(boardId, editingBoardName); editingBoardId = null; editingBoardName = ''; onUpdate?.(vm); } catch (error) { - console.error('Failed to rename board:', error); + reportActionError('Could not rename board', error); + } finally { + endAction(`rename:${boardId}`); } } @@ -196,34 +240,50 @@ ) { return; } + const board = vm.boards.find((item) => item.id === boardId); + if (!beginAction(`delete:${boardId}`)) return; try { await vm.actions.delete(boardId); - if (inspectorOpen && vm.selectedId === boardId) { + if (inspectorOpen && inspectorBoard?.id === boardId) { inspectorOpen = false; inspectorData = null; + inspectorBoard = null; } onUpdate?.(vm); + if (activeBoardId === boardId) { + const replacement = vm.boards.find((item) => item.id !== boardId); + if (replacement) { + await vm.actions.open(replacement.id); + } else { + const replacementId = await vm.actions.create('Untitled Board'); + await vm.actions.open(replacementId); + } + closeBrowser(); + } } catch (error) { - console.error('Failed to delete board:', error); + reportActionError(`Could not delete ${board?.name ?? 'board'}`, error); + } finally { + endAction(`delete:${boardId}`); } } async function handleInspectBoard(board: BoardMeta) { - if (!fetchInspectorData) { - console.warn('Inspector data fetcher not provided'); - return; - } - + inspectorBoard = board; inspectorOpen = true; inspectorLoading = true; inspectorError = null; + inspectorData = null; + + if (!fetchInspectorData) { + inspectorLoading = false; + inspectorError = 'Board diagnostics are not available on this platform.'; + return; + } try { inspectorData = await fetchInspectorData(board.id); } catch (error) { - inspectorError = - error instanceof Error ? error.message : 'Failed to load inspector data'; - inspectorData = null; + inspectorError = describeError(error); } finally { inspectorLoading = false; } @@ -259,10 +319,6 @@ return currentFilePath || activeBoard?.storage?.location || workspaceDir || 'IndexedDB'; } - function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); - } - function startRename(board: BoardMeta) { editingBoardId = board.id; editingBoardName = board.name; @@ -321,10 +377,19 @@

Boards

+ {#if actionError} + + {/if} @@ -448,6 +513,8 @@ class="filebrowser__btn filebrowser__btn--primary" variant="primary" size="small" + busy={actionBusy === 'create'} + disabled={actionBusy !== null} onclick={handleCreateBoard}> Create @@ -506,6 +573,8 @@ class="filebrowser__board-info" data-board-id={board.id} id={board.id} + disabled={actionBusy !== null} + aria-busy={actionBusy === `open:${board.id}`} tabindex={vm.selectedId === board.id ? 0 : -1} onfocus={() => selectBoard(board.id)} onkeydown={(event) => handleBoardKeydown(event, board.id)} @@ -526,6 +595,7 @@ +