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