web based infinite canvas

feat: desktop persistence

+1033 -51
+7 -7
TODO.txt
··· 219 219 R3. Desktop: real directory + files (Tauri) 220 220 -------------------------------------------------------------------------------- 221 221 222 - [ ] Add "Workspace folder" concept: 222 + [x] Add "Workspace folder" concept: 223 223 - pick directory 224 224 - remember last workspace path 225 - [ ] Implement directory listing: 225 + [x] Implement directory listing: 226 226 - show *.inkfinite.json files in workspace 227 227 - tree view with folders 228 - [ ] Implement file actions: 228 + [x] Implement file actions: 229 229 - [x] New: create new file 230 - - [ ] Rename: rename file (Tauri command needed) 231 - - [ ] Delete: delete file (Tauri command needed) 230 + - [x] Rename: rename file (Tauri command needed) 231 + - [x] Delete: delete file (Tauri command needed) 232 232 - [x] Open: load file into editor 233 233 - [x] Export: save JSON 234 234 ··· 239 239 R4. Parity behaviors 240 240 -------------------------------------------------------------------------------- 241 241 242 - [ ] Same shortcuts: 242 + [x] Same shortcuts: 243 243 - Ctrl/Cmd+O opens file browser 244 244 - Ctrl/Cmd+N creates board 245 - [ ] Consistent metadata display: 245 + [x] Consistent metadata display: 246 246 - name + updatedAt in both modes 247 247 248 248 (DoD):
+120
apps/desktop/src-tauri/src/lib.rs
··· 1 + use std::fs; 2 + use std::path::{Path, PathBuf}; 3 + use tauri::AppHandle; 4 + 5 + #[derive(serde::Serialize, serde::Deserialize)] 6 + pub struct FileEntry { 7 + pub path: String, 8 + pub name: String, 9 + pub is_dir: bool, 10 + } 11 + 12 + /// Read directory contents and return matching files 13 + #[tauri::command] 14 + fn read_directory(directory: String, pattern: Option<String>) -> Result<Vec<FileEntry>, String> { 15 + let path = Path::new(&directory); 16 + if !path.exists() { 17 + return Err(format!("Directory does not exist: {}", directory)); 18 + } 19 + if !path.is_dir() { 20 + return Err(format!("Path is not a directory: {}", directory)); 21 + } 22 + 23 + let entries = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?; 24 + 25 + let mut results = Vec::new(); 26 + let pattern = pattern.unwrap_or_else(|| "*.inkfinite.json".to_string()); 27 + 28 + for entry in entries { 29 + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; 30 + let entry_path = entry.path(); 31 + let metadata = entry 32 + .metadata() 33 + .map_err(|e| format!("Failed to read metadata: {}", e))?; 34 + 35 + let name = entry.file_name().to_string_lossy().to_string(); 36 + 37 + if metadata.is_file() { 38 + if pattern.contains('*') { 39 + let pattern_without_star = pattern.replace('*', ""); 40 + if !name.contains(&pattern_without_star) { 41 + continue; 42 + } 43 + } else if !name.ends_with(&pattern) { 44 + continue; 45 + } 46 + } 47 + 48 + results.push(FileEntry { 49 + path: entry_path.to_string_lossy().to_string(), 50 + name, 51 + is_dir: metadata.is_dir(), 52 + }); 53 + } 54 + 55 + // Sort: directories first, then files, alphabetically 56 + results.sort_by(|a, b| { 57 + if a.is_dir == b.is_dir { 58 + a.name.to_lowercase().cmp(&b.name.to_lowercase()) 59 + } else if a.is_dir { 60 + std::cmp::Ordering::Less 61 + } else { 62 + std::cmp::Ordering::Greater 63 + } 64 + }); 65 + 66 + Ok(results) 67 + } 68 + 69 + /// Rename a file 70 + #[tauri::command] 71 + fn rename_file(old_path: String, new_path: String) -> Result<(), String> { 72 + let old = Path::new(&old_path); 73 + let new = Path::new(&new_path); 74 + 75 + if !old.exists() { 76 + return Err(format!("Source file does not exist: {}", old_path)); 77 + } 78 + 79 + fs::rename(old, new).map_err(|e| format!("Failed to rename file: {}", e))?; 80 + 81 + Ok(()) 82 + } 83 + 84 + /// Delete a file 85 + #[tauri::command] 86 + fn delete_file(file_path: String) -> Result<(), String> { 87 + let path = Path::new(&file_path); 88 + 89 + if !path.exists() { 90 + return Err(format!("File does not exist: {}", file_path)); 91 + } 92 + 93 + if path.is_dir() { 94 + return Err(format!("Path is a directory, not a file: {}", file_path)); 95 + } 96 + 97 + fs::remove_file(path).map_err(|e| format!("Failed to delete file: {}", e))?; 98 + 99 + Ok(()) 100 + } 101 + 102 + /// Pick a workspace directory using the system folder picker 103 + #[tauri::command] 104 + async fn pick_workspace_directory(app: AppHandle) -> Result<Option<String>, String> { 105 + use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 106 + 107 + let result = app.dialog().file().blocking_pick_folder(); 108 + 109 + match result { 110 + Some(path) => Ok(Some(path.to_string_lossy().to_string())), 111 + None => Ok(None), 112 + } 113 + } 114 + 1 115 #[cfg_attr(mobile, tauri::mobile_entry_point)] 2 116 pub fn run() { 3 117 tauri::Builder::default() ··· 5 119 .plugin(tauri_plugin_dialog::init()) 6 120 .plugin(tauri_plugin_fs::init()) 7 121 .plugin(tauri_plugin_store::Builder::default().build()) 122 + .invoke_handler(tauri::generate_handler![ 123 + read_directory, 124 + rename_file, 125 + delete_file, 126 + pick_workspace_directory 127 + ]) 8 128 .run(tauri::generate_context!()) 9 129 .expect("error while running tauri application"); 10 130 }
+2 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 82 82 bind:open={c.fileBrowser.open} 83 83 onUpdate={c.fileBrowser.handleUpdate} 84 84 fetchInspectorData={c.fileBrowser.fetchInspectorData} 85 - onClose={c.fileBrowser.handleClose} /> 85 + onClose={c.fileBrowser.handleClose} 86 + desktopRepo={c.desktop.repo} /> 86 87 {/if} 87 88 </div> 88 89
+21 -14
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 1 - import { createInputAdapter, type InputAdapter } from "$lib/input"; 1 + import { createInputAdapter } from "$lib/input"; 2 + import type { InputAdapter } from "$lib/input"; 2 3 import type { DesktopDocRepo } from "$lib/persistence/desktop"; 3 4 import { createPlatformRepo, detectPlatform } from "$lib/platform"; 4 - import { 5 - createPersistenceManager, 6 - createSnapStore, 7 - createStatusStore, 8 - type SnapStore, 9 - type StatusStore, 10 - } from "$lib/status"; 5 + import { createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 + import type { SnapStore, StatusStore } from "$lib/status"; 11 7 import { 12 - type Action, 13 8 ArrowTool, 14 9 Camera, 15 10 createId, ··· 21 16 getShapesOnCurrentPage, 22 17 InkfiniteDB, 23 18 LineTool, 24 - type LoadedDoc, 25 - type PersistenceSink, 26 - type PersistentDocRepo, 27 19 RectTool, 28 20 routeAction, 29 21 SelectTool, ··· 32 24 SnapshotCommand, 33 25 Store, 34 26 TextTool, 35 - type Viewport, 36 27 } from "inkfinite-core"; 28 + import type { Action, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core"; 37 29 import { createRenderer, type Renderer } from "inkfinite-renderer"; 38 30 import { onDestroy, onMount } from "svelte"; 39 31 import { SvelteSet } from "svelte/reactivity"; ··· 251 243 if (action.type !== "key-down") { 252 244 return null; 253 245 } 246 + 247 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 248 + 249 + // Global shortcuts (work regardless of selection) 250 + if (primaryModifier && (action.key === "o" || action.key === "O")) { 251 + // Open file browser 252 + fileBrowser.handleOpen(); 253 + return null; 254 + } 255 + 256 + if (primaryModifier && (action.key === "n" || action.key === "N")) { 257 + // New board - open file browser in create mode 258 + fileBrowser.handleOpen(); 259 + return null; 260 + } 261 + 254 262 const selectionIds = state.ui.selectionIds; 255 263 if (selectionIds.length === 0) { 256 264 return null; ··· 290 298 } 291 299 } 292 300 293 - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 294 301 if (primaryModifier && (action.key === "d" || action.key === "D")) { 295 302 return duplicateSelection(state); 296 303 }
+4
apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts
··· 15 15 private onLoadDoc: (boardId: string, doc: LoadedDoc) => void, 16 16 ) {} 17 17 18 + get repo(): DesktopDocRepo | null { 19 + return this.getDesktopRepo(); 20 + } 21 + 18 22 private updateFileState = () => { 19 23 const desktopRepo = this.getDesktopRepo(); 20 24 if (!desktopRepo) {
+135 -1
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 1 1 <script lang="ts"> 2 2 import Icon from '$lib/components/Icon.svelte'; 3 3 import Sheet from '$lib/components/Sheet.svelte'; 4 + import type { DesktopDocRepo } from '$lib/persistence/desktop'; 4 5 import type { 5 6 BoardInspectorData, 6 7 BoardMeta, ··· 20 21 open?: boolean; 21 22 onClose?: () => void; 22 23 children?: Snippet; 24 + desktopRepo?: DesktopDocRepo | null; 23 25 }; 24 26 25 27 let { ··· 28 30 fetchInspectorData, 29 31 open = $bindable(false), 30 32 onClose: handleClose, 31 - children: _children 33 + children: _children, 34 + desktopRepo = null 32 35 }: Props = $props(); 33 36 34 37 let searchQuery = $state(vm.query); ··· 41 44 let newBoardName = $state(''); 42 45 let editingBoardId = $state<string | null>(null); 43 46 let editingBoardName = $state(''); 47 + 48 + let workspaceDir = $state<string | null>(null); 49 + 50 + $effect(() => { 51 + if (desktopRepo && open) { 52 + desktopRepo.getWorkspaceDir().then((dir) => { 53 + workspaceDir = dir; 54 + }); 55 + } 56 + }); 44 57 45 58 function handleSearchInput(event: Event) { 46 59 const target = event.target as HTMLInputElement; ··· 142 155 editingBoardId = null; 143 156 editingBoardName = ''; 144 157 } 158 + 159 + async function handlePickWorkspace() { 160 + if (!desktopRepo) return; 161 + try { 162 + const dir = await desktopRepo.pickWorkspaceDir(); 163 + if (dir) { 164 + workspaceDir = dir; 165 + onUpdate?.(vm); 166 + } 167 + } catch (error) { 168 + console.error('Failed to pick workspace:', error); 169 + } 170 + } 171 + 172 + async function handleClearWorkspace() { 173 + if (!desktopRepo) return; 174 + try { 175 + await desktopRepo.setWorkspaceDir(null); 176 + workspaceDir = null; 177 + onUpdate?.(vm); 178 + } catch (error) { 179 + console.error('Failed to clear workspace:', error); 180 + } 181 + } 145 182 </script> 146 183 147 184 <Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet"> ··· 166 203 </button> 167 204 </div> 168 205 206 + {#if desktopRepo} 207 + <div class="filebrowser__workspace"> 208 + {#if workspaceDir} 209 + <div class="filebrowser__workspace-info"> 210 + <Icon name="folder" size={16} /> 211 + <span class="filebrowser__workspace-path" title={workspaceDir}> 212 + {workspaceDir.split('/').pop() || workspaceDir} 213 + </span> 214 + <button 215 + class="filebrowser__workspace-change" 216 + onclick={handlePickWorkspace} 217 + aria-label="Change workspace"> 218 + Change 219 + </button> 220 + <button 221 + class="filebrowser__workspace-clear" 222 + onclick={handleClearWorkspace} 223 + aria-label="Clear workspace"> 224 + × 225 + </button> 226 + </div> 227 + {:else} 228 + <button 229 + class="filebrowser__workspace-pick" 230 + onclick={handlePickWorkspace} 231 + aria-label="Pick workspace folder"> 232 + <Icon name="folder" size={16} /> 233 + Pick Workspace Folder 234 + </button> 235 + <div class="filebrowser__workspace-hint">Recent files mode</div> 236 + {/if} 237 + </div> 238 + {/if} 239 + 169 240 <div class="filebrowser__search"> 170 241 <!-- FIXME: reactivity is broken --> 171 242 <input ··· 441 512 442 513 .filebrowser__action:hover { 443 514 background-color: var(--primary-hover, #0056b3); 515 + } 516 + 517 + .filebrowser__workspace { 518 + padding: 0.75rem 1rem; 519 + border-bottom: 1px solid var(--border, #e0e0e0); 520 + background-color: var(--surface-secondary, #f9f9f9); 521 + } 522 + 523 + .filebrowser__workspace-info { 524 + display: flex; 525 + align-items: center; 526 + gap: 0.5rem; 527 + font-size: 0.875rem; 528 + } 529 + 530 + .filebrowser__workspace-path { 531 + flex: 1; 532 + overflow: hidden; 533 + text-overflow: ellipsis; 534 + white-space: nowrap; 535 + font-family: monospace; 536 + color: var(--text); 537 + } 538 + 539 + .filebrowser__workspace-change, 540 + .filebrowser__workspace-clear { 541 + padding: 0.25rem 0.5rem; 542 + background-color: transparent; 543 + border: 1px solid var(--border, #e0e0e0); 544 + border-radius: 4px; 545 + cursor: pointer; 546 + font-size: 0.75rem; 547 + color: var(--text); 548 + } 549 + 550 + .filebrowser__workspace-change:hover, 551 + .filebrowser__workspace-clear:hover { 552 + background-color: var(--surface-hover, #f5f5f5); 553 + } 554 + 555 + .filebrowser__workspace-pick { 556 + display: flex; 557 + align-items: center; 558 + gap: 0.5rem; 559 + width: 100%; 560 + padding: 0.5rem; 561 + background-color: var(--primary, #007bff); 562 + color: white; 563 + border: none; 564 + border-radius: 4px; 565 + cursor: pointer; 566 + font-size: 0.875rem; 567 + } 568 + 569 + .filebrowser__workspace-pick:hover { 570 + background-color: var(--primary-hover, #0056b3); 571 + } 572 + 573 + .filebrowser__workspace-hint { 574 + margin-top: 0.5rem; 575 + font-size: 0.75rem; 576 + color: var(--text-muted, #6c757d); 577 + text-align: center; 444 578 } 445 579 446 580 .filebrowser__search {
+45 -5
apps/web/src/lib/fileops.ts
··· 1 - /** 2 - * Desktop file operations using Tauri plugins 3 - */ 4 - 1 + import { invoke } from "@tauri-apps/api/core"; 5 2 import { open, save } from "@tauri-apps/plugin-dialog"; 6 3 import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; 7 4 import { load } from "@tauri-apps/plugin-store"; 8 - import type { DesktopFileOps, FileHandle } from "inkfinite-core"; 5 + import type { DesktopFileOps, DirectoryEntry, FileHandle } from "inkfinite-core"; 9 6 10 7 export type { DesktopFileOps }; 11 8 12 9 const STORE_NAME = "inkfinite-desktop.json"; 13 10 const RECENT_FILES_KEY = "recentFiles"; 11 + const WORKSPACE_DIR_KEY = "workspaceDir"; 14 12 const MAX_RECENT_FILES = 10; 13 + 14 + type FileEntry = { path: string; name: string; is_dir: boolean }; 15 15 16 16 /** 17 17 * Create desktop file operations using Tauri APIs ··· 82 82 await store.save(); 83 83 } 84 84 85 + async function getWorkspaceDir(): Promise<string | null> { 86 + const store = await getStore(); 87 + const workspace = (await store.get<string | null>(WORKSPACE_DIR_KEY)) || null; 88 + return workspace; 89 + } 90 + 91 + async function setWorkspaceDir(path: string | null): Promise<void> { 92 + const store = await getStore(); 93 + await store.set(WORKSPACE_DIR_KEY, path); 94 + await store.save(); 95 + } 96 + 97 + async function pickWorkspaceDir(): Promise<string | null> { 98 + const result = await invoke<string | null>("pick_workspace_directory"); 99 + if (result) { 100 + await setWorkspaceDir(result); 101 + } 102 + return result; 103 + } 104 + 105 + async function readDirectory(directory: string, pattern?: string): Promise<DirectoryEntry[]> { 106 + const entries = await invoke<FileEntry[]>("read_directory", { directory, pattern: pattern || "*.inkfinite.json" }); 107 + 108 + return entries.map((e) => ({ path: e.path, name: e.name, isDir: e.is_dir })); 109 + } 110 + 111 + async function renameFile(oldPath: string, newPath: string): Promise<void> { 112 + await invoke("rename_file", { oldPath, newPath }); 113 + } 114 + 115 + async function deleteFile(path: string): Promise<void> { 116 + await invoke("delete_file", { filePath: path }); 117 + } 118 + 85 119 return { 86 120 showOpenDialog, 87 121 showSaveDialog, ··· 91 125 addRecentFile, 92 126 removeRecentFile, 93 127 clearRecentFiles, 128 + getWorkspaceDir, 129 + setWorkspaceDir, 130 + pickWorkspaceDir, 131 + readDirectory, 132 + renameFile, 133 + deleteFile, 94 134 }; 95 135 }
+119 -23
apps/web/src/lib/persistence/desktop.ts
··· 19 19 kind: "desktop"; 20 20 getCurrentFile(): FileHandle | null; 21 21 openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; 22 + getWorkspaceDir(): Promise<string | null>; 23 + setWorkspaceDir(path: string | null): Promise<void>; 24 + pickWorkspaceDir(): Promise<string | null>; 22 25 }; 23 26 24 27 export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo { ··· 63 66 } 64 67 65 68 async function listBoards(): Promise<BoardMeta[]> { 66 - const recent = await fileOps.getRecentFiles(); 69 + const workspaceDir = await fileOps.getWorkspaceDir(); 67 70 const boards: BoardMeta[] = []; 68 71 69 - for (const handle of recent) { 72 + if (workspaceDir) { 73 + // Workspace mode: list files from workspace directory 70 74 try { 71 - const content = await fileOps.readFile(handle.path); 72 - const fileData = parseDesktopFile(content); 73 - boards.push(fileData.board); 74 - boardFiles.set(fileData.board.id, handle); 75 - } catch { 76 - await fileOps.removeRecentFile(handle.path); 75 + const entries = await fileOps.readDirectory(workspaceDir, "*.inkfinite.json"); 76 + 77 + for (const entry of entries) { 78 + if (entry.isDir) continue; 79 + 80 + try { 81 + const content = await fileOps.readFile(entry.path); 82 + const fileData = parseDesktopFile(content); 83 + boards.push(fileData.board); 84 + boardFiles.set(fileData.board.id, { path: entry.path, name: entry.name }); 85 + } catch (error) { 86 + console.warn(`Failed to load board from ${entry.path}:`, error); 87 + } 88 + } 89 + } catch (error) { 90 + console.error("Failed to read workspace directory:", error); 91 + } 92 + } else { 93 + // Recent files mode 94 + const recent = await fileOps.getRecentFiles(); 95 + 96 + for (const handle of recent) { 97 + try { 98 + const content = await fileOps.readFile(handle.path); 99 + const fileData = parseDesktopFile(content); 100 + boards.push(fileData.board); 101 + boardFiles.set(fileData.board.id, handle); 102 + } catch { 103 + await fileOps.removeRecentFile(handle.path); 104 + } 77 105 } 78 106 } 79 107 ··· 101 129 shapeOrder: { [page.id]: [] }, 102 130 }); 103 131 104 - const path = await fileOps.showSaveDialog(`${name || "Untitled"}.inkfinite.json`); 105 - if (!path) { 106 - throw new Error("Save cancelled"); 132 + const workspaceDir = await fileOps.getWorkspaceDir(); 133 + let path: string | null; 134 + 135 + if (workspaceDir) { 136 + // Workspace mode: save directly in workspace directory 137 + const fileName = `${name || "Untitled"}.inkfinite.json`; 138 + path = `${workspaceDir}/${fileName}`; 139 + } else { 140 + // Recent files mode: show save dialog 141 + path = await fileOps.showSaveDialog(`${name || "Untitled"}.inkfinite.json`); 142 + if (!path) { 143 + throw new Error("Save cancelled"); 144 + } 107 145 } 108 146 109 147 await fileOps.writeFile(path, serializeDesktopFile(fileData)); ··· 111 149 const handle = { path, name: path.split("/").pop() || name }; 112 150 setCurrentState(handle, board, loadedDocFromFileData(fileData)); 113 151 114 - await fileOps.addRecentFile(handle); 152 + if (!workspaceDir) { 153 + await fileOps.addRecentFile(handle); 154 + } 155 + 115 156 return boardId; 116 157 } 117 158 ··· 123 164 throw new Error("No board loaded"); 124 165 } 125 166 126 - currentBoard = { ...currentBoard, name, updatedAt: Date.now() }; 167 + const oldPath = currentFile.path; 168 + const workspaceDir = await fileOps.getWorkspaceDir(); 127 169 128 - const fileData = createFileData( 129 - currentBoard, 130 - currentDoc.pages, 131 - currentDoc.shapes, 132 - currentDoc.bindings, 133 - currentDoc.order, 134 - ); 170 + // If we're renaming the file itself (in workspace mode) 171 + if (workspaceDir) { 172 + const dir = oldPath.substring(0, oldPath.lastIndexOf("/")); 173 + const newFileName = `${name}.inkfinite.json`; 174 + const newPath = `${dir}/${newFileName}`; 175 + 176 + // Update board metadata 177 + currentBoard = { ...currentBoard, name, updatedAt: Date.now() }; 135 178 136 - await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData)); 137 - boardFiles.set(currentBoard.id, currentFile); 179 + const fileData = createFileData( 180 + currentBoard, 181 + currentDoc.pages, 182 + currentDoc.shapes, 183 + currentDoc.bindings, 184 + currentDoc.order, 185 + ); 186 + 187 + // Write to new path and delete old file (atomic rename not always possible cross-filesystem) 188 + await fileOps.writeFile(newPath, serializeDesktopFile(fileData)); 189 + 190 + if (newPath !== oldPath) { 191 + try { 192 + await fileOps.deleteFile(oldPath); 193 + } catch (error) { 194 + console.warn("Failed to delete old file:", error); 195 + } 196 + } 197 + 198 + // Update current file handle 199 + const newHandle = { path: newPath, name: newFileName }; 200 + currentFile = newHandle; 201 + boardFiles.set(currentBoard.id, newHandle); 202 + } else { 203 + // Recent files mode: just update the content 204 + currentBoard = { ...currentBoard, name, updatedAt: Date.now() }; 205 + 206 + const fileData = createFileData( 207 + currentBoard, 208 + currentDoc.pages, 209 + currentDoc.shapes, 210 + currentDoc.bindings, 211 + currentDoc.order, 212 + ); 213 + 214 + await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData)); 215 + boardFiles.set(currentBoard.id, currentFile); 216 + } 138 217 } 139 218 140 219 async function deleteBoard(boardId: string): Promise<void> { 141 220 const handle = boardFiles.get(boardId); 221 + const workspaceDir = await fileOps.getWorkspaceDir(); 222 + 142 223 if (handle) { 143 - await fileOps.removeRecentFile(handle.path); 224 + if (workspaceDir) { 225 + // Workspace mode: actually delete the file 226 + try { 227 + await fileOps.deleteFile(handle.path); 228 + } catch (error) { 229 + console.error("Failed to delete file:", error); 230 + throw error; 231 + } 232 + } else { 233 + // Recent files mode: just remove from recent list 234 + await fileOps.removeRecentFile(handle.path); 235 + } 144 236 boardFiles.delete(boardId); 145 237 } 238 + 146 239 if (currentBoard?.id === boardId) { 147 240 currentFile = null; 148 241 currentBoard = null; ··· 300 393 importBoard, 301 394 getCurrentFile: () => currentFile, 302 395 openFromDialog, 396 + getWorkspaceDir: () => fileOps.getWorkspaceDir(), 397 + setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path), 398 + pickWorkspaceDir: () => fileOps.pickWorkspaceDir(), 303 399 }; 304 400 } 305 401
+295
apps/web/src/lib/tests/desktop-workspace.test.ts
··· 1 + /** 2 + * Unit tests for desktop workspace functionality 3 + */ 4 + 5 + import { createDesktopDocRepo } from "$lib/persistence/desktop"; 6 + import type { DesktopFileOps, DirectoryEntry } from "inkfinite-core"; 7 + import { beforeEach, describe, expect, it, vi } from "vitest"; 8 + 9 + describe("Desktop workspace mode", () => { 10 + let fileOps: DesktopFileOps; 11 + let mockWorkspaceDir: string | null; 12 + let mockFiles: Map<string, string>; 13 + let mockDirectoryEntries: DirectoryEntry[]; 14 + 15 + beforeEach(() => { 16 + mockWorkspaceDir = null; 17 + mockFiles = new Map(); 18 + mockDirectoryEntries = []; 19 + 20 + fileOps = { 21 + showOpenDialog: vi.fn(async () => "/test/path.inkfinite.json"), 22 + showSaveDialog: vi.fn(async (defaultName?: string) => `/test/${defaultName || "file.inkfinite.json"}`), 23 + readFile: vi.fn(async (path: string) => { 24 + const content = mockFiles.get(path); 25 + if (!content) throw new Error(`File not found: ${path}`); 26 + return content; 27 + }), 28 + writeFile: vi.fn(async (path: string, content: string) => { 29 + mockFiles.set(path, content); 30 + }), 31 + getRecentFiles: vi.fn(async () => []), 32 + addRecentFile: vi.fn(async () => {}), 33 + removeRecentFile: vi.fn(async () => {}), 34 + clearRecentFiles: vi.fn(async () => {}), 35 + getWorkspaceDir: vi.fn(async () => mockWorkspaceDir), 36 + setWorkspaceDir: vi.fn(async (path: string | null) => { 37 + mockWorkspaceDir = path; 38 + }), 39 + pickWorkspaceDir: vi.fn(async () => { 40 + mockWorkspaceDir = "/test/workspace"; 41 + return mockWorkspaceDir; 42 + }), 43 + readDirectory: vi.fn(async (_directory: string, _pattern?: string) => { 44 + return mockDirectoryEntries; 45 + }), 46 + renameFile: vi.fn(async (oldPath: string, newPath: string) => { 47 + const content = mockFiles.get(oldPath); 48 + if (!content) throw new Error(`File not found: ${oldPath}`); 49 + mockFiles.set(newPath, content); 50 + mockFiles.delete(oldPath); 51 + }), 52 + deleteFile: vi.fn(async (path: string) => { 53 + if (!mockFiles.has(path)) throw new Error(`File not found: ${path}`); 54 + mockFiles.delete(path); 55 + }), 56 + }; 57 + }); 58 + 59 + describe("Workspace directory management", () => { 60 + it("should get workspace directory", async () => { 61 + const repo = createDesktopDocRepo(fileOps); 62 + mockWorkspaceDir = "/test/workspace"; 63 + const dir = await repo.getWorkspaceDir(); 64 + expect(dir).toBe("/test/workspace"); 65 + }); 66 + 67 + it("should set workspace directory", async () => { 68 + const repo = createDesktopDocRepo(fileOps); 69 + await repo.setWorkspaceDir("/new/workspace"); 70 + expect(mockWorkspaceDir).toBe("/new/workspace"); 71 + }); 72 + 73 + it("should clear workspace directory", async () => { 74 + const repo = createDesktopDocRepo(fileOps); 75 + mockWorkspaceDir = "/test/workspace"; 76 + await repo.setWorkspaceDir(null); 77 + expect(mockWorkspaceDir).toBeNull(); 78 + }); 79 + 80 + it("should pick workspace directory", async () => { 81 + const repo = createDesktopDocRepo(fileOps); 82 + const dir = await repo.pickWorkspaceDir(); 83 + expect(dir).toBe("/test/workspace"); 84 + expect(mockWorkspaceDir).toBe("/test/workspace"); 85 + }); 86 + }); 87 + 88 + describe("listBoards with workspace mode", () => { 89 + it("should list boards from workspace directory", async () => { 90 + const repo = createDesktopDocRepo(fileOps); 91 + mockWorkspaceDir = "/workspace"; 92 + 93 + const board1Content = JSON.stringify({ 94 + board: { id: "board-1", name: "Board 1", createdAt: 1000, updatedAt: 2000 }, 95 + doc: { pages: {}, shapes: {}, bindings: {} }, 96 + order: { pageIds: [], shapeOrder: {} }, 97 + }); 98 + 99 + const board2Content = JSON.stringify({ 100 + board: { id: "board-2", name: "Board 2", createdAt: 1500, updatedAt: 2500 }, 101 + doc: { pages: {}, shapes: {}, bindings: {} }, 102 + order: { pageIds: [], shapeOrder: {} }, 103 + }); 104 + 105 + mockFiles.set("/workspace/board1.inkfinite.json", board1Content); 106 + mockFiles.set("/workspace/board2.inkfinite.json", board2Content); 107 + 108 + mockDirectoryEntries = [ 109 + { path: "/workspace/board1.inkfinite.json", name: "board1.inkfinite.json", isDir: false }, 110 + { path: "/workspace/board2.inkfinite.json", name: "board2.inkfinite.json", isDir: false }, 111 + ]; 112 + 113 + const boards = await repo.listBoards(); 114 + 115 + expect(boards).toHaveLength(2); 116 + expect(boards[0].name).toBe("Board 2"); // Sorted by updatedAt descending 117 + expect(boards[1].name).toBe("Board 1"); 118 + }); 119 + 120 + it("should skip directories when listing workspace", async () => { 121 + const repo = createDesktopDocRepo(fileOps); 122 + mockWorkspaceDir = "/workspace"; 123 + 124 + const boardContent = JSON.stringify({ 125 + board: { id: "board-1", name: "Board 1", createdAt: 1000, updatedAt: 2000 }, 126 + doc: { pages: {}, shapes: {}, bindings: {} }, 127 + order: { pageIds: [], shapeOrder: {} }, 128 + }); 129 + 130 + mockFiles.set("/workspace/board.inkfinite.json", boardContent); 131 + 132 + mockDirectoryEntries = [{ path: "/workspace/board.inkfinite.json", name: "board.inkfinite.json", isDir: false }, { 133 + path: "/workspace/subfolder", 134 + name: "subfolder", 135 + isDir: true, 136 + }]; 137 + 138 + const boards = await repo.listBoards(); 139 + 140 + expect(boards).toHaveLength(1); 141 + expect(boards[0].name).toBe("Board 1"); 142 + }); 143 + 144 + it("should fall back to recent files when no workspace set", async () => { 145 + const repo = createDesktopDocRepo(fileOps); 146 + mockWorkspaceDir = null; 147 + 148 + const boardContent = JSON.stringify({ 149 + board: { id: "board-1", name: "Recent Board", createdAt: 1000, updatedAt: 2000 }, 150 + doc: { pages: {}, shapes: {}, bindings: {} }, 151 + order: { pageIds: [], shapeOrder: {} }, 152 + }); 153 + 154 + mockFiles.set("/recent/board.inkfinite.json", boardContent); 155 + 156 + fileOps.getRecentFiles = vi.fn( 157 + async () => [{ path: "/recent/board.inkfinite.json", name: "board.inkfinite.json" }] 158 + ); 159 + 160 + const boards = await repo.listBoards(); 161 + 162 + expect(boards).toHaveLength(1); 163 + expect(boards[0].name).toBe("Recent Board"); 164 + }); 165 + }); 166 + 167 + describe("createBoard with workspace mode", () => { 168 + it("should save new board in workspace directory", async () => { 169 + const repo = createDesktopDocRepo(fileOps); 170 + mockWorkspaceDir = "/workspace"; 171 + 172 + const boardId = await repo.createBoard("Test Board"); 173 + 174 + expect(boardId).toBeTruthy(); 175 + expect(mockFiles.has("/workspace/Test Board.inkfinite.json")).toBe(true); 176 + 177 + const fileContent = mockFiles.get("/workspace/Test Board.inkfinite.json")!; 178 + const data = JSON.parse(fileContent); 179 + expect(data.board.name).toBe("Test Board"); 180 + }); 181 + 182 + it("should show save dialog when no workspace set", async () => { 183 + const repo = createDesktopDocRepo(fileOps); 184 + mockWorkspaceDir = null; 185 + 186 + await repo.createBoard("Test Board"); 187 + 188 + expect(fileOps.showSaveDialog).toHaveBeenCalledWith("Test Board.inkfinite.json"); 189 + }); 190 + }); 191 + 192 + describe("renameBoard with workspace mode", () => { 193 + it("should rename file and update content in workspace mode", async () => { 194 + const repo = createDesktopDocRepo(fileOps); 195 + mockWorkspaceDir = "/workspace"; 196 + 197 + const originalPath = "/workspace/Original.inkfinite.json"; 198 + const boardContent = JSON.stringify({ 199 + board: { id: "board-1", name: "Original", createdAt: 1000, updatedAt: 2000 }, 200 + doc: { pages: { "page-1": { id: "page-1", name: "Page 1", shapeIds: [] } }, shapes: {}, bindings: {} }, 201 + order: { pageIds: ["page-1"], shapeOrder: { "page-1": [] } }, 202 + }); 203 + 204 + mockFiles.set(originalPath, boardContent); 205 + mockDirectoryEntries = [{ path: originalPath, name: "Original.inkfinite.json", isDir: false }]; 206 + 207 + // List boards first to populate boardFiles map 208 + await repo.listBoards(); 209 + await repo.openBoard("board-1"); 210 + await repo.renameBoard("board-1", "Renamed"); 211 + 212 + const newPath = "/workspace/Renamed.inkfinite.json"; 213 + expect(mockFiles.has(newPath)).toBe(true); 214 + 215 + const newContent = mockFiles.get(newPath)!; 216 + const data = JSON.parse(newContent); 217 + expect(data.board.name).toBe("Renamed"); 218 + }); 219 + 220 + it("should update content only in recent files mode", async () => { 221 + const repo = createDesktopDocRepo(fileOps); 222 + mockWorkspaceDir = null; 223 + 224 + const path = "/recent/board.inkfinite.json"; 225 + const boardContent = JSON.stringify({ 226 + board: { id: "board-1", name: "Original", createdAt: 1000, updatedAt: 2000 }, 227 + doc: { pages: { "page-1": { id: "page-1", name: "Page 1", shapeIds: [] } }, shapes: {}, bindings: {} }, 228 + order: { pageIds: ["page-1"], shapeOrder: { "page-1": [] } }, 229 + }); 230 + 231 + mockFiles.set(path, boardContent); 232 + fileOps.getRecentFiles = vi.fn(async () => [{ path, name: "board.inkfinite.json" }]); 233 + 234 + // List boards first to populate boardFiles map 235 + await repo.listBoards(); 236 + await repo.openBoard("board-1"); 237 + await repo.renameBoard("board-1", "Renamed"); 238 + 239 + // File path should not change in recent files mode 240 + expect(mockFiles.has(path)).toBe(true); 241 + 242 + const newContent = mockFiles.get(path)!; 243 + const data = JSON.parse(newContent); 244 + expect(data.board.name).toBe("Renamed"); 245 + }); 246 + }); 247 + 248 + describe("deleteBoard with workspace mode", () => { 249 + it("should delete file in workspace mode", async () => { 250 + const repo = createDesktopDocRepo(fileOps); 251 + mockWorkspaceDir = "/workspace"; 252 + 253 + const path = "/workspace/board.inkfinite.json"; 254 + const boardContent = JSON.stringify({ 255 + board: { id: "board-1", name: "Board", createdAt: 1000, updatedAt: 2000 }, 256 + doc: { pages: {}, shapes: {}, bindings: {} }, 257 + order: { pageIds: [], shapeOrder: {} }, 258 + }); 259 + 260 + mockFiles.set(path, boardContent); 261 + mockDirectoryEntries = [{ path, name: "board.inkfinite.json", isDir: false }]; 262 + 263 + const boards = await repo.listBoards(); 264 + expect(boards).toHaveLength(1); 265 + 266 + await repo.deleteBoard("board-1"); 267 + 268 + expect(mockFiles.has(path)).toBe(false); 269 + expect(fileOps.deleteFile).toHaveBeenCalledWith(path); 270 + }); 271 + 272 + it("should remove from recent files in non-workspace mode", async () => { 273 + const repo = createDesktopDocRepo(fileOps); 274 + mockWorkspaceDir = null; 275 + 276 + const path = "/recent/board.inkfinite.json"; 277 + const boardContent = JSON.stringify({ 278 + board: { id: "board-1", name: "Board", createdAt: 1000, updatedAt: 2000 }, 279 + doc: { pages: {}, shapes: {}, bindings: {} }, 280 + order: { pageIds: [], shapeOrder: {} }, 281 + }); 282 + 283 + mockFiles.set(path, boardContent); 284 + fileOps.getRecentFiles = vi.fn(async () => [{ path, name: "board.inkfinite.json" }]); 285 + 286 + const boards = await repo.listBoards(); 287 + expect(boards).toHaveLength(1); 288 + 289 + await repo.deleteBoard("board-1"); 290 + 291 + expect(fileOps.removeRecentFile).toHaveBeenCalledWith(path); 292 + expect(fileOps.deleteFile).not.toHaveBeenCalled(); 293 + }); 294 + }); 295 + });
+222
apps/web/src/lib/tests/keyboard-shortcuts.test.ts
··· 1 + /** 2 + * Unit tests for keyboard shortcuts (Cmd+O, Cmd+N) 3 + */ 4 + 5 + import type { KeyDownAction } from "inkfinite-core"; 6 + import { describe, expect, it, vi } from "vitest"; 7 + 8 + describe("Keyboard shortcuts", () => { 9 + describe("Cmd+O / Ctrl+O (Open file browser)", () => { 10 + it("should trigger with Cmd+O on Mac", () => { 11 + const action: KeyDownAction = { 12 + type: "key-down", 13 + key: "o", 14 + code: "KeyO", 15 + modifiers: { ctrl: false, shift: false, alt: false, meta: true }, 16 + repeat: false, 17 + timestamp: Date.now(), 18 + }; 19 + 20 + const handleOpen = vi.fn(); 21 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 22 + 23 + if (primaryModifier && (action.key === "o" || action.key === "O")) { 24 + handleOpen(); 25 + } 26 + 27 + expect(handleOpen).toHaveBeenCalled(); 28 + }); 29 + 30 + it("should trigger with Ctrl+O on Windows/Linux", () => { 31 + const action: KeyDownAction = { 32 + type: "key-down", 33 + key: "o", 34 + code: "KeyO", 35 + modifiers: { ctrl: true, shift: false, alt: false, meta: false }, 36 + repeat: false, 37 + timestamp: Date.now(), 38 + }; 39 + 40 + const handleOpen = vi.fn(); 41 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 42 + 43 + if (primaryModifier && (action.key === "o" || action.key === "O")) { 44 + handleOpen(); 45 + } 46 + 47 + expect(handleOpen).toHaveBeenCalled(); 48 + }); 49 + 50 + it("should handle uppercase O", () => { 51 + const action: KeyDownAction = { 52 + type: "key-down", 53 + key: "O", 54 + code: "KeyO", 55 + modifiers: { ctrl: false, shift: true, alt: false, meta: true }, 56 + repeat: false, 57 + timestamp: Date.now(), 58 + }; 59 + 60 + const handleOpen = vi.fn(); 61 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 62 + 63 + if (primaryModifier && (action.key === "o" || action.key === "O")) { 64 + handleOpen(); 65 + } 66 + 67 + expect(handleOpen).toHaveBeenCalled(); 68 + }); 69 + 70 + it("should not trigger without modifier", () => { 71 + const action: KeyDownAction = { 72 + type: "key-down", 73 + key: "o", 74 + code: "KeyO", 75 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 76 + repeat: false, 77 + timestamp: Date.now(), 78 + }; 79 + 80 + const handleOpen = vi.fn(); 81 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 82 + 83 + if (primaryModifier && (action.key === "o" || action.key === "O")) { 84 + handleOpen(); 85 + } 86 + 87 + expect(handleOpen).not.toHaveBeenCalled(); 88 + }); 89 + }); 90 + 91 + describe("Cmd+N / Ctrl+N (New board)", () => { 92 + it("should trigger with Cmd+N on Mac", () => { 93 + const action: KeyDownAction = { 94 + type: "key-down", 95 + key: "n", 96 + code: "KeyN", 97 + modifiers: { ctrl: false, shift: false, alt: false, meta: true }, 98 + repeat: false, 99 + timestamp: Date.now(), 100 + }; 101 + 102 + const handleNew = vi.fn(); 103 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 104 + 105 + if (primaryModifier && (action.key === "n" || action.key === "N")) { 106 + handleNew(); 107 + } 108 + 109 + expect(handleNew).toHaveBeenCalled(); 110 + }); 111 + 112 + it("should trigger with Ctrl+N on Windows/Linux", () => { 113 + const action: KeyDownAction = { 114 + type: "key-down", 115 + key: "n", 116 + code: "KeyN", 117 + modifiers: { ctrl: true, shift: false, alt: false, meta: false }, 118 + repeat: false, 119 + timestamp: Date.now(), 120 + }; 121 + 122 + const handleNew = vi.fn(); 123 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 124 + 125 + if (primaryModifier && (action.key === "n" || action.key === "N")) { 126 + handleNew(); 127 + } 128 + 129 + expect(handleNew).toHaveBeenCalled(); 130 + }); 131 + 132 + it("should handle uppercase N", () => { 133 + const action: KeyDownAction = { 134 + type: "key-down", 135 + key: "N", 136 + code: "KeyN", 137 + modifiers: { ctrl: false, shift: true, alt: false, meta: true }, 138 + repeat: false, 139 + timestamp: Date.now(), 140 + }; 141 + 142 + const handleNew = vi.fn(); 143 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 144 + 145 + if (primaryModifier && (action.key === "n" || action.key === "N")) { 146 + handleNew(); 147 + } 148 + 149 + expect(handleNew).toHaveBeenCalled(); 150 + }); 151 + 152 + it("should not trigger without modifier", () => { 153 + const action: KeyDownAction = { 154 + type: "key-down", 155 + key: "n", 156 + code: "KeyN", 157 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 158 + repeat: false, 159 + timestamp: Date.now(), 160 + }; 161 + 162 + const handleNew = vi.fn(); 163 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 164 + 165 + if (primaryModifier && (action.key === "n" || action.key === "N")) { 166 + handleNew(); 167 + } 168 + 169 + expect(handleNew).not.toHaveBeenCalled(); 170 + }); 171 + }); 172 + 173 + describe("Other existing shortcuts", () => { 174 + it("should not conflict with Cmd+D (duplicate)", () => { 175 + const actionD: KeyDownAction = { 176 + type: "key-down", 177 + key: "d", 178 + code: "KeyD", 179 + modifiers: { ctrl: false, shift: false, alt: false, meta: true }, 180 + repeat: false, 181 + timestamp: Date.now(), 182 + }; 183 + 184 + const handleOpen = vi.fn(); 185 + const handleDuplicate = vi.fn(); 186 + const primaryModifier = actionD.modifiers.meta || actionD.modifiers.ctrl; 187 + 188 + if (primaryModifier && (actionD.key === "o" || actionD.key === "O")) { 189 + handleOpen(); 190 + } else if (primaryModifier && (actionD.key === "d" || actionD.key === "D")) { 191 + handleDuplicate(); 192 + } 193 + 194 + expect(handleOpen).not.toHaveBeenCalled(); 195 + expect(handleDuplicate).toHaveBeenCalled(); 196 + }); 197 + 198 + it("should not conflict with arrow key navigation", () => { 199 + const actionArrow: KeyDownAction = { 200 + type: "key-down", 201 + key: "ArrowLeft", 202 + code: "ArrowLeft", 203 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 204 + repeat: false, 205 + timestamp: Date.now(), 206 + }; 207 + 208 + const handleOpen = vi.fn(); 209 + const handleNav = vi.fn(); 210 + const primaryModifier = actionArrow.modifiers.meta || actionArrow.modifiers.ctrl; 211 + 212 + if (primaryModifier && (actionArrow.key === "o" || actionArrow.key === "O")) { 213 + handleOpen(); 214 + } else if (actionArrow.key.startsWith("Arrow")) { 215 + handleNav(); 216 + } 217 + 218 + expect(handleOpen).not.toHaveBeenCalled(); 219 + expect(handleNav).toHaveBeenCalled(); 220 + }); 221 + }); 222 + });
+28
apps/web/src/lib/tests/persistence.desktop.test.ts
··· 14 14 const recent: FileHandle[] = []; 15 15 let nextOpen: string | null = null; 16 16 let nextSave: string | null = null; 17 + let workspaceDir: string | null = null; 17 18 18 19 const ops: DesktopFileOps = { 19 20 async showOpenDialog() { ··· 51 52 }, 52 53 async clearRecentFiles() { 53 54 recent.splice(0, recent.length); 55 + }, 56 + async getWorkspaceDir() { 57 + return workspaceDir; 58 + }, 59 + async setWorkspaceDir(path) { 60 + workspaceDir = path; 61 + }, 62 + async pickWorkspaceDir() { 63 + workspaceDir = "/tmp/workspace"; 64 + return workspaceDir; 65 + }, 66 + async readDirectory(_directory, _pattern) { 67 + return []; 68 + }, 69 + async renameFile(oldPath, newPath) { 70 + const content = files.get(oldPath); 71 + if (content === undefined) { 72 + throw new Error(`Missing file: ${oldPath}`); 73 + } 74 + files.set(newPath, content); 75 + files.delete(oldPath); 76 + }, 77 + async deleteFile(path) { 78 + if (!files.has(path)) { 79 + throw new Error(`Missing file: ${path}`); 80 + } 81 + files.delete(path); 54 82 }, 55 83 }; 56 84
+35
packages/core/src/persistence/desktop.ts
··· 13 13 export type FileHandle = { path: string; name: string }; 14 14 15 15 /** 16 + * Directory entry from file system 17 + */ 18 + export type DirectoryEntry = { path: string; name: string; isDir: boolean }; 19 + 20 + /** 16 21 * Desktop-specific operations interface. 17 22 * Implementation lives in apps/desktop using @tauri-apps/plugin-* APIs. 18 23 */ ··· 56 61 * Clear all recent files 57 62 */ 58 63 clearRecentFiles(): Promise<void>; 64 + 65 + /** 66 + * Get current workspace directory 67 + */ 68 + getWorkspaceDir(): Promise<string | null>; 69 + 70 + /** 71 + * Set workspace directory 72 + */ 73 + setWorkspaceDir(path: string | null): Promise<void>; 74 + 75 + /** 76 + * Show directory picker and set as workspace 77 + */ 78 + pickWorkspaceDir(): Promise<string | null>; 79 + 80 + /** 81 + * Read directory contents (filtered by pattern) 82 + */ 83 + readDirectory(directory: string, pattern?: string): Promise<DirectoryEntry[]>; 84 + 85 + /** 86 + * Rename a file on disk 87 + */ 88 + renameFile(oldPath: string, newPath: string): Promise<void>; 89 + 90 + /** 91 + * Delete a file from disk 92 + */ 93 + deleteFile(path: string): Promise<void>; 59 94 } 60 95 61 96 /**