tangled
alpha
login
or
join now
desertthunder.dev
/
inkfinite
web based infinite canvas
2
fork
atom
overview
issues
pulls
pipelines
feat: desktop persistence
desertthunder.dev
1 month ago
10653292
a781ab32
+1033
-51
12 changed files
expand all
collapse all
unified
split
TODO.txt
apps
desktop
src-tauri
src
lib.rs
web
src
lib
canvas
Canvas.svelte
canvas-store.svelte.ts
controllers
desktop-file-controller.svelte.ts
filebrowser
FileBrowser.svelte
fileops.ts
persistence
desktop.ts
tests
desktop-workspace.test.ts
keyboard-shortcuts.test.ts
persistence.desktop.test.ts
packages
core
src
persistence
desktop.ts
+7
-7
TODO.txt
···
219
R3. Desktop: real directory + files (Tauri)
220
--------------------------------------------------------------------------------
221
222
-
[ ] Add "Workspace folder" concept:
223
- pick directory
224
- remember last workspace path
225
-
[ ] Implement directory listing:
226
- show *.inkfinite.json files in workspace
227
- tree view with folders
228
-
[ ] Implement file actions:
229
- [x] New: create new file
230
-
- [ ] Rename: rename file (Tauri command needed)
231
-
- [ ] Delete: delete file (Tauri command needed)
232
- [x] Open: load file into editor
233
- [x] Export: save JSON
234
···
239
R4. Parity behaviors
240
--------------------------------------------------------------------------------
241
242
-
[ ] Same shortcuts:
243
- Ctrl/Cmd+O opens file browser
244
- Ctrl/Cmd+N creates board
245
-
[ ] Consistent metadata display:
246
- name + updatedAt in both modes
247
248
(DoD):
···
219
R3. Desktop: real directory + files (Tauri)
220
--------------------------------------------------------------------------------
221
222
+
[x] Add "Workspace folder" concept:
223
- pick directory
224
- remember last workspace path
225
+
[x] Implement directory listing:
226
- show *.inkfinite.json files in workspace
227
- tree view with folders
228
+
[x] Implement file actions:
229
- [x] New: create new file
230
+
- [x] Rename: rename file (Tauri command needed)
231
+
- [x] Delete: delete file (Tauri command needed)
232
- [x] Open: load file into editor
233
- [x] Export: save JSON
234
···
239
R4. Parity behaviors
240
--------------------------------------------------------------------------------
241
242
+
[x] Same shortcuts:
243
- Ctrl/Cmd+O opens file browser
244
- Ctrl/Cmd+N creates board
245
+
[x] Consistent metadata display:
246
- name + updatedAt in both modes
247
248
(DoD):
+120
apps/desktop/src-tauri/src/lib.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
#[cfg_attr(mobile, tauri::mobile_entry_point)]
2
pub fn run() {
3
tauri::Builder::default()
···
5
.plugin(tauri_plugin_dialog::init())
6
.plugin(tauri_plugin_fs::init())
7
.plugin(tauri_plugin_store::Builder::default().build())
0
0
0
0
0
0
8
.run(tauri::generate_context!())
9
.expect("error while running tauri application");
10
}
···
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
+
115
#[cfg_attr(mobile, tauri::mobile_entry_point)]
116
pub fn run() {
117
tauri::Builder::default()
···
119
.plugin(tauri_plugin_dialog::init())
120
.plugin(tauri_plugin_fs::init())
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
+
])
128
.run(tauri::generate_context!())
129
.expect("error while running tauri application");
130
}
+2
-1
apps/web/src/lib/canvas/Canvas.svelte
···
82
bind:open={c.fileBrowser.open}
83
onUpdate={c.fileBrowser.handleUpdate}
84
fetchInspectorData={c.fileBrowser.fetchInspectorData}
85
-
onClose={c.fileBrowser.handleClose} />
0
86
{/if}
87
</div>
88
···
82
bind:open={c.fileBrowser.open}
83
onUpdate={c.fileBrowser.handleUpdate}
84
fetchInspectorData={c.fileBrowser.fetchInspectorData}
85
+
onClose={c.fileBrowser.handleClose}
86
+
desktopRepo={c.desktop.repo} />
87
{/if}
88
</div>
89
+21
-14
apps/web/src/lib/canvas/canvas-store.svelte.ts
···
1
-
import { createInputAdapter, type InputAdapter } from "$lib/input";
0
2
import type { DesktopDocRepo } from "$lib/persistence/desktop";
3
import { createPlatformRepo, detectPlatform } from "$lib/platform";
4
-
import {
5
-
createPersistenceManager,
6
-
createSnapStore,
7
-
createStatusStore,
8
-
type SnapStore,
9
-
type StatusStore,
10
-
} from "$lib/status";
11
import {
12
-
type Action,
13
ArrowTool,
14
Camera,
15
createId,
···
21
getShapesOnCurrentPage,
22
InkfiniteDB,
23
LineTool,
24
-
type LoadedDoc,
25
-
type PersistenceSink,
26
-
type PersistentDocRepo,
27
RectTool,
28
routeAction,
29
SelectTool,
···
32
SnapshotCommand,
33
Store,
34
TextTool,
35
-
type Viewport,
36
} from "inkfinite-core";
0
37
import { createRenderer, type Renderer } from "inkfinite-renderer";
38
import { onDestroy, onMount } from "svelte";
39
import { SvelteSet } from "svelte/reactivity";
···
251
if (action.type !== "key-down") {
252
return null;
253
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
254
const selectionIds = state.ui.selectionIds;
255
if (selectionIds.length === 0) {
256
return null;
···
290
}
291
}
292
293
-
const primaryModifier = action.modifiers.meta || action.modifiers.ctrl;
294
if (primaryModifier && (action.key === "d" || action.key === "D")) {
295
return duplicateSelection(state);
296
}
···
1
+
import { createInputAdapter } from "$lib/input";
2
+
import type { InputAdapter } from "$lib/input";
3
import type { DesktopDocRepo } from "$lib/persistence/desktop";
4
import { createPlatformRepo, detectPlatform } from "$lib/platform";
5
+
import { createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status";
6
+
import type { SnapStore, StatusStore } from "$lib/status";
0
0
0
0
0
7
import {
0
8
ArrowTool,
9
Camera,
10
createId,
···
16
getShapesOnCurrentPage,
17
InkfiniteDB,
18
LineTool,
0
0
0
19
RectTool,
20
routeAction,
21
SelectTool,
···
24
SnapshotCommand,
25
Store,
26
TextTool,
0
27
} from "inkfinite-core";
28
+
import type { Action, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core";
29
import { createRenderer, type Renderer } from "inkfinite-renderer";
30
import { onDestroy, onMount } from "svelte";
31
import { SvelteSet } from "svelte/reactivity";
···
243
if (action.type !== "key-down") {
244
return null;
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
+
262
const selectionIds = state.ui.selectionIds;
263
if (selectionIds.length === 0) {
264
return null;
···
298
}
299
}
300
0
301
if (primaryModifier && (action.key === "d" || action.key === "D")) {
302
return duplicateSelection(state);
303
}
+4
apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts
···
15
private onLoadDoc: (boardId: string, doc: LoadedDoc) => void,
16
) {}
17
0
0
0
0
18
private updateFileState = () => {
19
const desktopRepo = this.getDesktopRepo();
20
if (!desktopRepo) {
···
15
private onLoadDoc: (boardId: string, doc: LoadedDoc) => void,
16
) {}
17
18
+
get repo(): DesktopDocRepo | null {
19
+
return this.getDesktopRepo();
20
+
}
21
+
22
private updateFileState = () => {
23
const desktopRepo = this.getDesktopRepo();
24
if (!desktopRepo) {
+135
-1
apps/web/src/lib/filebrowser/FileBrowser.svelte
···
1
<script lang="ts">
2
import Icon from '$lib/components/Icon.svelte';
3
import Sheet from '$lib/components/Sheet.svelte';
0
4
import type {
5
BoardInspectorData,
6
BoardMeta,
···
20
open?: boolean;
21
onClose?: () => void;
22
children?: Snippet;
0
23
};
24
25
let {
···
28
fetchInspectorData,
29
open = $bindable(false),
30
onClose: handleClose,
31
-
children: _children
0
32
}: Props = $props();
33
34
let searchQuery = $state(vm.query);
···
41
let newBoardName = $state('');
42
let editingBoardId = $state<string | null>(null);
43
let editingBoardName = $state('');
0
0
0
0
0
0
0
0
0
0
44
45
function handleSearchInput(event: Event) {
46
const target = event.target as HTMLInputElement;
···
142
editingBoardId = null;
143
editingBoardName = '';
144
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
145
</script>
146
147
<Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet">
···
166
</button>
167
</div>
168
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
169
<div class="filebrowser__search">
170
<!-- FIXME: reactivity is broken -->
171
<input
···
441
442
.filebrowser__action:hover {
443
background-color: var(--primary-hover, #0056b3);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
444
}
445
446
.filebrowser__search {
···
1
<script lang="ts">
2
import Icon from '$lib/components/Icon.svelte';
3
import Sheet from '$lib/components/Sheet.svelte';
4
+
import type { DesktopDocRepo } from '$lib/persistence/desktop';
5
import type {
6
BoardInspectorData,
7
BoardMeta,
···
21
open?: boolean;
22
onClose?: () => void;
23
children?: Snippet;
24
+
desktopRepo?: DesktopDocRepo | null;
25
};
26
27
let {
···
30
fetchInspectorData,
31
open = $bindable(false),
32
onClose: handleClose,
33
+
children: _children,
34
+
desktopRepo = null
35
}: Props = $props();
36
37
let searchQuery = $state(vm.query);
···
44
let newBoardName = $state('');
45
let editingBoardId = $state<string | null>(null);
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
+
});
57
58
function handleSearchInput(event: Event) {
59
const target = event.target as HTMLInputElement;
···
155
editingBoardId = null;
156
editingBoardName = '';
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
+
}
182
</script>
183
184
<Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet">
···
203
</button>
204
</div>
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
+
240
<div class="filebrowser__search">
241
<!-- FIXME: reactivity is broken -->
242
<input
···
512
513
.filebrowser__action:hover {
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;
578
}
579
580
.filebrowser__search {
+45
-5
apps/web/src/lib/fileops.ts
···
1
-
/**
2
-
* Desktop file operations using Tauri plugins
3
-
*/
4
-
5
import { open, save } from "@tauri-apps/plugin-dialog";
6
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
7
import { load } from "@tauri-apps/plugin-store";
8
-
import type { DesktopFileOps, FileHandle } from "inkfinite-core";
9
10
export type { DesktopFileOps };
11
12
const STORE_NAME = "inkfinite-desktop.json";
13
const RECENT_FILES_KEY = "recentFiles";
0
14
const MAX_RECENT_FILES = 10;
0
0
15
16
/**
17
* Create desktop file operations using Tauri APIs
···
82
await store.save();
83
}
84
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
85
return {
86
showOpenDialog,
87
showSaveDialog,
···
91
addRecentFile,
92
removeRecentFile,
93
clearRecentFiles,
0
0
0
0
0
0
94
};
95
}
···
1
+
import { invoke } from "@tauri-apps/api/core";
0
0
0
2
import { open, save } from "@tauri-apps/plugin-dialog";
3
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
4
import { load } from "@tauri-apps/plugin-store";
5
+
import type { DesktopFileOps, DirectoryEntry, FileHandle } from "inkfinite-core";
6
7
export type { DesktopFileOps };
8
9
const STORE_NAME = "inkfinite-desktop.json";
10
const RECENT_FILES_KEY = "recentFiles";
11
+
const WORKSPACE_DIR_KEY = "workspaceDir";
12
const MAX_RECENT_FILES = 10;
13
+
14
+
type FileEntry = { path: string; name: string; is_dir: boolean };
15
16
/**
17
* Create desktop file operations using Tauri APIs
···
82
await store.save();
83
}
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
+
119
return {
120
showOpenDialog,
121
showSaveDialog,
···
125
addRecentFile,
126
removeRecentFile,
127
clearRecentFiles,
128
+
getWorkspaceDir,
129
+
setWorkspaceDir,
130
+
pickWorkspaceDir,
131
+
readDirectory,
132
+
renameFile,
133
+
deleteFile,
134
};
135
}
+119
-23
apps/web/src/lib/persistence/desktop.ts
···
19
kind: "desktop";
20
getCurrentFile(): FileHandle | null;
21
openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>;
0
0
0
22
};
23
24
export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo {
···
63
}
64
65
async function listBoards(): Promise<BoardMeta[]> {
66
-
const recent = await fileOps.getRecentFiles();
67
const boards: BoardMeta[] = [];
68
69
-
for (const handle of recent) {
0
70
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);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
77
}
78
}
79
···
101
shapeOrder: { [page.id]: [] },
102
});
103
104
-
const path = await fileOps.showSaveDialog(`${name || "Untitled"}.inkfinite.json`);
105
-
if (!path) {
106
-
throw new Error("Save cancelled");
0
0
0
0
0
0
0
0
0
0
107
}
108
109
await fileOps.writeFile(path, serializeDesktopFile(fileData));
···
111
const handle = { path, name: path.split("/").pop() || name };
112
setCurrentState(handle, board, loadedDocFromFileData(fileData));
113
114
-
await fileOps.addRecentFile(handle);
0
0
0
115
return boardId;
116
}
117
···
123
throw new Error("No board loaded");
124
}
125
126
-
currentBoard = { ...currentBoard, name, updatedAt: Date.now() };
0
127
128
-
const fileData = createFileData(
129
-
currentBoard,
130
-
currentDoc.pages,
131
-
currentDoc.shapes,
132
-
currentDoc.bindings,
133
-
currentDoc.order,
134
-
);
0
135
136
-
await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData));
137
-
boardFiles.set(currentBoard.id, currentFile);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
138
}
139
140
async function deleteBoard(boardId: string): Promise<void> {
141
const handle = boardFiles.get(boardId);
0
0
142
if (handle) {
143
-
await fileOps.removeRecentFile(handle.path);
0
0
0
0
0
0
0
0
0
0
0
144
boardFiles.delete(boardId);
145
}
0
146
if (currentBoard?.id === boardId) {
147
currentFile = null;
148
currentBoard = null;
···
300
importBoard,
301
getCurrentFile: () => currentFile,
302
openFromDialog,
0
0
0
303
};
304
}
305
···
19
kind: "desktop";
20
getCurrentFile(): FileHandle | null;
21
openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>;
22
+
getWorkspaceDir(): Promise<string | null>;
23
+
setWorkspaceDir(path: string | null): Promise<void>;
24
+
pickWorkspaceDir(): Promise<string | null>;
25
};
26
27
export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo {
···
66
}
67
68
async function listBoards(): Promise<BoardMeta[]> {
69
+
const workspaceDir = await fileOps.getWorkspaceDir();
70
const boards: BoardMeta[] = [];
71
72
+
if (workspaceDir) {
73
+
// Workspace mode: list files from workspace directory
74
try {
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
+
}
105
}
106
}
107
···
129
shapeOrder: { [page.id]: [] },
130
});
131
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
+
}
145
}
146
147
await fileOps.writeFile(path, serializeDesktopFile(fileData));
···
149
const handle = { path, name: path.split("/").pop() || name };
150
setCurrentState(handle, board, loadedDocFromFileData(fileData));
151
152
+
if (!workspaceDir) {
153
+
await fileOps.addRecentFile(handle);
154
+
}
155
+
156
return boardId;
157
}
158
···
164
throw new Error("No board loaded");
165
}
166
167
+
const oldPath = currentFile.path;
168
+
const workspaceDir = await fileOps.getWorkspaceDir();
169
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() };
178
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
+
}
217
}
218
219
async function deleteBoard(boardId: string): Promise<void> {
220
const handle = boardFiles.get(boardId);
221
+
const workspaceDir = await fileOps.getWorkspaceDir();
222
+
223
if (handle) {
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
+
}
236
boardFiles.delete(boardId);
237
}
238
+
239
if (currentBoard?.id === boardId) {
240
currentFile = null;
241
currentBoard = null;
···
393
importBoard,
394
getCurrentFile: () => currentFile,
395
openFromDialog,
396
+
getWorkspaceDir: () => fileOps.getWorkspaceDir(),
397
+
setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path),
398
+
pickWorkspaceDir: () => fileOps.pickWorkspaceDir(),
399
};
400
}
401
+295
apps/web/src/lib/tests/desktop-workspace.test.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
const recent: FileHandle[] = [];
15
let nextOpen: string | null = null;
16
let nextSave: string | null = null;
0
17
18
const ops: DesktopFileOps = {
19
async showOpenDialog() {
···
51
},
52
async clearRecentFiles() {
53
recent.splice(0, recent.length);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
54
},
55
};
56
···
14
const recent: FileHandle[] = [];
15
let nextOpen: string | null = null;
16
let nextSave: string | null = null;
17
+
let workspaceDir: string | null = null;
18
19
const ops: DesktopFileOps = {
20
async showOpenDialog() {
···
52
},
53
async clearRecentFiles() {
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);
82
},
83
};
84
+35
packages/core/src/persistence/desktop.ts
···
13
export type FileHandle = { path: string; name: string };
14
15
/**
0
0
0
0
0
16
* Desktop-specific operations interface.
17
* Implementation lives in apps/desktop using @tauri-apps/plugin-* APIs.
18
*/
···
56
* Clear all recent files
57
*/
58
clearRecentFiles(): Promise<void>;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
59
}
60
61
/**
···
13
export type FileHandle = { path: string; name: string };
14
15
/**
16
+
* Directory entry from file system
17
+
*/
18
+
export type DirectoryEntry = { path: string; name: string; isDir: boolean };
19
+
20
+
/**
21
* Desktop-specific operations interface.
22
* Implementation lives in apps/desktop using @tauri-apps/plugin-* APIs.
23
*/
···
61
* Clear all recent files
62
*/
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>;
94
}
95
96
/**