diff --git a/TODO.txt b/TODO.txt index 3f8f2da..f27bda7 100644 --- a/TODO.txt +++ b/TODO.txt @@ -219,16 +219,16 @@ Safe deletes: R3. Desktop: real directory + files (Tauri) -------------------------------------------------------------------------------- -[ ] Add "Workspace folder" concept: +[x] Add "Workspace folder" concept: - pick directory - remember last workspace path -[ ] Implement directory listing: +[x] Implement directory listing: - show *.inkfinite.json files in workspace - tree view with folders -[ ] Implement file actions: +[x] Implement file actions: - [x] New: create new file - - [ ] Rename: rename file (Tauri command needed) - - [ ] Delete: delete file (Tauri command needed) + - [x] Rename: rename file (Tauri command needed) + - [x] Delete: delete file (Tauri command needed) - [x] Open: load file into editor - [x] Export: save JSON @@ -239,10 +239,10 @@ R3. Desktop: real directory + files (Tauri) R4. Parity behaviors -------------------------------------------------------------------------------- -[ ] Same shortcuts: +[x] Same shortcuts: - Ctrl/Cmd+O opens file browser - Ctrl/Cmd+N creates board -[ ] Consistent metadata display: +[x] Consistent metadata display: - name + updatedAt in both modes (DoD): diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6dba14f..1f7b5e5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,117 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct FileEntry { + pub path: String, + pub name: String, + pub is_dir: bool, +} + +/// Read directory contents and return matching files +#[tauri::command] +fn read_directory(directory: String, pattern: Option) -> Result, String> { + let path = Path::new(&directory); + if !path.exists() { + return Err(format!("Directory does not exist: {}", directory)); + } + if !path.is_dir() { + return Err(format!("Path is not a directory: {}", directory)); + } + + let entries = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?; + + let mut results = Vec::new(); + let pattern = pattern.unwrap_or_else(|| "*.inkfinite.json".to_string()); + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let entry_path = entry.path(); + let metadata = entry + .metadata() + .map_err(|e| format!("Failed to read metadata: {}", e))?; + + let name = entry.file_name().to_string_lossy().to_string(); + + if metadata.is_file() { + if pattern.contains('*') { + let pattern_without_star = pattern.replace('*', ""); + if !name.contains(&pattern_without_star) { + continue; + } + } else if !name.ends_with(&pattern) { + continue; + } + } + + results.push(FileEntry { + path: entry_path.to_string_lossy().to_string(), + name, + is_dir: metadata.is_dir(), + }); + } + + // Sort: directories first, then files, alphabetically + results.sort_by(|a, b| { + if a.is_dir == b.is_dir { + a.name.to_lowercase().cmp(&b.name.to_lowercase()) + } else if a.is_dir { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + + Ok(results) +} + +/// Rename a file +#[tauri::command] +fn rename_file(old_path: String, new_path: String) -> Result<(), String> { + let old = Path::new(&old_path); + let new = Path::new(&new_path); + + if !old.exists() { + return Err(format!("Source file does not exist: {}", old_path)); + } + + fs::rename(old, new).map_err(|e| format!("Failed to rename file: {}", e))?; + + Ok(()) +} + +/// Delete a file +#[tauri::command] +fn delete_file(file_path: String) -> Result<(), String> { + let path = Path::new(&file_path); + + if !path.exists() { + return Err(format!("File does not exist: {}", file_path)); + } + + if path.is_dir() { + return Err(format!("Path is a directory, not a file: {}", file_path)); + } + + fs::remove_file(path).map_err(|e| format!("Failed to delete file: {}", e))?; + + Ok(()) +} + +/// Pick a workspace directory using the system folder picker +#[tauri::command] +async fn pick_workspace_directory(app: AppHandle) -> Result, String> { + use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; + + let result = app.dialog().file().blocking_pick_folder(); + + match result { + Some(path) => Ok(Some(path.to_string_lossy().to_string())), + None => Ok(None), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -5,6 +119,12 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) + .invoke_handler(tauri::generate_handler![ + read_directory, + rename_file, + delete_file, + pick_workspace_directory + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index a01e038..5e37ea1 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -82,7 +82,8 @@ bind:open={c.fileBrowser.open} onUpdate={c.fileBrowser.handleUpdate} fetchInspectorData={c.fileBrowser.fetchInspectorData} - onClose={c.fileBrowser.handleClose} /> + onClose={c.fileBrowser.handleClose} + desktopRepo={c.desktop.repo} /> {/if} diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index b94f4cc..f80cf1a 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -1,15 +1,10 @@ -import { createInputAdapter, type InputAdapter } from "$lib/input"; +import { createInputAdapter } from "$lib/input"; +import type { InputAdapter } from "$lib/input"; import type { DesktopDocRepo } from "$lib/persistence/desktop"; import { createPlatformRepo, detectPlatform } from "$lib/platform"; +import { createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; +import type { SnapStore, StatusStore } from "$lib/status"; import { - createPersistenceManager, - createSnapStore, - createStatusStore, - type SnapStore, - type StatusStore, -} from "$lib/status"; -import { - type Action, ArrowTool, Camera, createId, @@ -21,9 +16,6 @@ import { getShapesOnCurrentPage, InkfiniteDB, LineTool, - type LoadedDoc, - type PersistenceSink, - type PersistentDocRepo, RectTool, routeAction, SelectTool, @@ -32,8 +24,8 @@ import { SnapshotCommand, Store, TextTool, - type Viewport, } from "inkfinite-core"; +import type { Action, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core"; import { createRenderer, type Renderer } from "inkfinite-renderer"; import { onDestroy, onMount } from "svelte"; import { SvelteSet } from "svelte/reactivity"; @@ -251,6 +243,22 @@ export function createCanvasController(bindings: CanvasControllerBindings) { if (action.type !== "key-down") { return null; } + + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; + + // Global shortcuts (work regardless of selection) + if (primaryModifier && (action.key === "o" || action.key === "O")) { + // Open file browser + fileBrowser.handleOpen(); + return null; + } + + if (primaryModifier && (action.key === "n" || action.key === "N")) { + // New board - open file browser in create mode + fileBrowser.handleOpen(); + return null; + } + const selectionIds = state.ui.selectionIds; if (selectionIds.length === 0) { return null; @@ -290,7 +298,6 @@ export function createCanvasController(bindings: CanvasControllerBindings) { } } - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; if (primaryModifier && (action.key === "d" || action.key === "D")) { return duplicateSelection(state); } diff --git a/apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts b/apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts index db84780..d8bc528 100644 --- a/apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts +++ b/apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts @@ -15,6 +15,10 @@ export class DesktopFileController { private onLoadDoc: (boardId: string, doc: LoadedDoc) => void, ) {} + get repo(): DesktopDocRepo | null { + return this.getDesktopRepo(); + } + private updateFileState = () => { const desktopRepo = this.getDesktopRepo(); if (!desktopRepo) { diff --git a/apps/web/src/lib/filebrowser/FileBrowser.svelte b/apps/web/src/lib/filebrowser/FileBrowser.svelte index 00bb0c7..4a83fe4 100644 --- a/apps/web/src/lib/filebrowser/FileBrowser.svelte +++ b/apps/web/src/lib/filebrowser/FileBrowser.svelte @@ -1,6 +1,7 @@ @@ -166,6 +203,40 @@ + {#if desktopRepo} +
+ {#if workspaceDir} +
+ + + {workspaceDir.split('/').pop() || workspaceDir} + + + +
+ {:else} + +
Recent files mode
+ {/if} +
+ {/if} +