From a9261de433447cd839c0a1acd937d652bd55439f Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 17 Jul 2026 22:55:51 -0500 Subject: [PATCH] feat: tauri commands for ops --- Cargo.lock | 2 +- ROADMAP.md | 18 +- TODO.md | 12 +- apps/desktop/src-tauri/Cargo.toml | 3 +- .../src-tauri/capabilities/default.json | 2 +- apps/desktop/src-tauri/src/files.rs | 106 ++ apps/desktop/src-tauri/src/lib.rs | 139 +-- apps/desktop/src-tauri/src/session.rs | 258 ++++ apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/web/package.json | 5 +- .../web/src/lib/canvas/canvas-store.svelte.ts | 35 +- .../filebrowser-controller.svelte.ts | 16 +- .../src/lib/filebrowser/FileBrowser.svelte | 32 +- apps/web/src/lib/fileops.ts | 15 +- .../src/lib/persistence/desktop-session.ts | 762 ++++++++++++ apps/web/src/lib/persistence/desktop.ts | 417 +------ apps/web/src/lib/platform.ts | 6 +- .../src/lib/tests/desktop-workspace.test.ts | 349 +----- .../src/lib/tests/persistence.desktop.test.ts | 441 +++++-- apps/web/src/routes/+layout.svelte | 1 + apps/web/vite.config.ts | 3 + .../src/bin/generate-bindings.rs | 112 +- crates/inkfinite-core/src/crdt/mod.rs | 134 +-- crates/inkfinite-core/src/crdt/tests.rs | 31 +- crates/inkfinite-core/src/engine/mod.rs | 1057 +++++------------ crates/inkfinite-core/src/engine/tests.rs | 138 +-- crates/inkfinite-core/src/file/migration.rs | 179 +-- crates/inkfinite-core/src/file/mod.rs | 4 +- crates/inkfinite-core/src/file/persistence.rs | 301 ++--- crates/inkfinite-core/src/file/tests.rs | 86 +- crates/inkfinite-core/src/lib.rs | 483 ++++---- crates/inkfinite-core/src/proto/mod.rs | 28 +- crates/inkfinite-core/src/session.rs | 548 +++++++++ crates/inkfinite-core/tests/bindings.rs | 54 +- package.json | 42 +- packages/core/src/persistence/desktop.ts | 15 +- pnpm-lock.yaml | 16 +- rustfmt.toml | 6 + 38 files changed, 3073 insertions(+), 2787 deletions(-) create mode 100644 apps/desktop/src-tauri/src/files.rs create mode 100644 apps/desktop/src-tauri/src/session.rs create mode 100644 apps/web/src/lib/persistence/desktop-session.ts create mode 100644 crates/inkfinite-core/src/session.rs create mode 100644 rustfmt.toml diff --git a/Cargo.lock b/Cargo.lock index 828be20..c68a155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,12 +779,12 @@ dependencies = [ name = "desktop" version = "0.1.0" dependencies = [ + "inkfinite-core", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-store", ] diff --git a/ROADMAP.md b/ROADMAP.md index ac4f323..b1244c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,12 +37,11 @@ the CLI and a bundled `SKILL.md`; MCP and UI automation are not part of vNext. - The pnpm monorepo contains a TypeScript core, Canvas 2D renderer, SvelteKit web UI, and Tauri 2 wrapper. - `@inkfinite/ui` provides shared Svelte components, theme tokens, fonts, and - icons for the browser and the Tauri-hosted frontend. Application-specific - components still live under `apps/web` and must move to the package as their - dependencies are separated. + icons for the browser and the Tauri-hosted frontend. `apps/web` imports the + shared stylesheet once and uses shared controls in its desktop vertical slice. - TypeScript currently owns a flat page/shape model, snapshot undo/redo, tools, - and persistence. Web documents use Dexie; desktop persistence is called from - the frontend. The Rust backend only exposes file-management helpers. + and browser persistence. Web documents use Dexie; desktop documents use a + thin adapter over Rust-owned sessions and typed Tauri commands. - `createCanvasController` combines tools, persistence, overlays, rendering, and input. The renderer walks every shape on the page and resizes its backing canvas on each draw. @@ -173,6 +172,15 @@ replacement, holds advisory locks, and retains bounded recovery snapshots plus encoded change journals across failed writes. JSON export is deterministic and history-free, as documented in [docs/v2-file-format.md](docs/v2-file-format.md). +V2-08 completed on July 17, 2026. `inkfinite-core::session::SessionService` +owns desktop paths, Automerge state, materialized snapshots, actor-scoped +undo/redo, dirty state, advisory locks, recovery visibility, and an explicit +disabled sync state. Tauri exposes typed create/open/snapshot/commit/history/ +save/query/validate/close commands, while `apps/web` keeps an in-memory editing +mirror and a thin metadata/dialog adapter. Desktop document bytes no longer +cross the frontend file APIs. Recovery and failure-path tests cover stale heads, +failed validation, interrupted writes, and save-as path replacement. + One Inkfinite transaction maps to one Automerge change. Causal heads, rather than a scalar revision, are the concurrency token. A local sequence number may be displayed, but callers use inspected heads and operation preconditions. diff --git a/TODO.md b/TODO.md index 8e811cd..59eaeff 100644 --- a/TODO.md +++ b/TODO.md @@ -231,19 +231,19 @@ Blocked by: V2-05, V2-06, V2-07 Acceptance criteria: -- [ ] Sessions track path, CRDT state, materialized snapshot, actor undo/redo, +- [x] Sessions track path, CRDT state, materialized snapshot, actor undo/redo, dirty state, locks, recovery, and sync state. -- [ ] Create/open/snapshot/commit/undo/redo/save/save-as/query/validate/close +- [x] Create/open/snapshot/commit/undo/redo/save/save-as/query/validate/close commands call shared crates and return typed errors and patches. -- [ ] File I/O leaves the frontend and plugin capabilities are reduced to the +- [x] File I/O leaves the frontend and plugin capabilities are reduced to the minimum still required. -- [ ] `apps/web` imports `@inkfinite/ui/styles.css` once and uses shared package +- [x] `apps/web` imports `@inkfinite/ui/styles.css` once and uses shared package components for the desktop vertical slice instead of adding new local copies under `apps/web/src/lib/components`. -- [ ] `apps/desktop` builds and packages the same `apps/web` frontend. Desktop-only +- [x] `apps/desktop` builds and packages the same `apps/web` frontend. Desktop-only behavior stays in Tauri commands or thin adapters, while components, themes, fonts, and icons come from `@inkfinite/ui`. -- [ ] Integration tests cover open, edit, save, reopen, undo, failed validation, +- [x] Integration tests cover open, edit, save, reopen, undo, failed validation, stale heads, and a simulated write failure. Verification: diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 80fe028..1b63a50 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -21,8 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" -tauri-plugin-fs = "2" tauri-plugin-store = "2" +inkfinite-core.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" - diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index ea5dcc2..b31fc83 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": ["core:default", "opener:default", "dialog:default", "fs:default", "store:default"] + "permissions": ["core:default", "opener:default", "dialog:default", "store:default"] } diff --git a/apps/desktop/src-tauri/src/files.rs b/apps/desktop/src-tauri/src/files.rs new file mode 100644 index 0000000..a60eb97 --- /dev/null +++ b/apps/desktop/src-tauri/src/files.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::path::Path; +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] +pub 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() { + let suffix = pattern.strip_prefix('*').unwrap_or(&pattern); + if !name.ends_with(suffix) { + 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] +pub 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] +pub 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] +pub async fn pick_workspace_directory(app: AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let result = app.dialog().file().blocking_pick_folder(); + + match result { + Some(path) => path + .into_path() + .map(|path| Some(path.to_string_lossy().into_owned())) + .map_err(|error| format!("Failed to resolve selected directory: {error}")), + None => Ok(None), + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6ad64f9..1d6b2b0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,132 +1,29 @@ -use std::fs; -use std::path::Path; -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; - - let result = app.dialog().file().blocking_pick_folder(); - - match result { - Some(path) => path - .into_path() - .map(|path| Some(path.to_string_lossy().into_owned())) - .map_err(|error| format!("Failed to resolve selected directory: {error}")), - None => Ok(None), - } -} +mod files; +mod session; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(session::DesktopState::default()) .plugin(tauri_plugin_opener::init()) .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 + session::create_document, + session::open_document, + session::snapshot, + session::commit, + session::undo, + session::redo, + session::save, + session::save_as, + session::query, + session::validate, + session::close, + files::read_directory, + files::rename_file, + files::delete_file, + files::pick_workspace_directory ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs new file mode 100644 index 0000000..7345aa2 --- /dev/null +++ b/apps/desktop/src-tauri/src/session.rs @@ -0,0 +1,258 @@ +//! Typed Tauri commands for Rust-owned document sessions. + +use std::sync::{Mutex, MutexGuard}; + +use inkfinite_core::proto::{DocumentPath, ProtocolError, Query, QueryResult, SessionId, TransactionDraft}; +use inkfinite_core::session::{ + SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, +}; +use inkfinite_core::{ActorId, ChangeHash, DocumentId}; +use tauri::State; + +/// Tauri-managed owner of every open document session in this app process. +pub struct DesktopState { + service: Mutex, +} + +impl Default for DesktopState { + fn default() -> Self { + Self { service: Mutex::new(SessionService::new()) } + } +} + +fn lock_service(state: &DesktopState) -> Result, ProtocolError> { + state.service.lock().map_err(|_| ProtocolError { + code: "session_service_unavailable".into(), + message: "the desktop session service lock is poisoned".into(), + details: None, + }) +} + +fn to_protocol_error(error: SessionError) -> ProtocolError { + let code = match &error { + SessionError::NotFound(_) => "session_not_found", + SessionError::ActorMismatch { .. } => "actor_mismatch", + SessionError::StaleHeads => "stale_heads", + SessionError::AlreadyOpen { .. } => "document_already_open", + SessionError::File(file_error) => match file_error { + inkfinite_core::file::FileError::Locked { .. } => "document_locked", + inkfinite_core::file::FileError::AlreadyExists { .. } => "document_already_exists", + inkfinite_core::file::FileError::InvalidV1(_) + | inkfinite_core::file::FileError::Json(_) + | inkfinite_core::file::FileError::UnsupportedFormat { .. } + | inkfinite_core::file::FileError::UnsupportedShapeKind { .. } + | inkfinite_core::file::FileError::SamePath { .. } + | inkfinite_core::file::FileError::RecoveryNotFound { .. } + | inkfinite_core::file::FileError::InvalidRecovery(_) + | inkfinite_core::file::FileError::RecoveryAhead { .. } + | inkfinite_core::file::FileError::Engine(_) + | inkfinite_core::file::FileError::Io { .. } => "document_file_error", + }, + SessionError::Engine(_) => "document_engine_error", + }; + ProtocolError { code: code.into(), message: error.to_string(), details: None } +} + +/// Creates a new canonical `.inkfinite` file and opens its session. +#[tauri::command] +pub fn create_document( + state: State<'_, DesktopState>, path: String, document_id: String, actor_id: String, page_name: Option, +) -> Result { + lock_service(&state)? + .create( + path, + DocumentId::new(document_id), + ActorId::new(actor_id), + page_name.as_deref(), + ) + .map_err(to_protocol_error) +} + +/// Opens a canonical document or imports a selected frozen v1 JSON file. +#[tauri::command] +pub fn open_document( + state: State<'_, DesktopState>, path: String, actor_id: String, +) -> Result { + lock_service(&state)? + .open(path, ActorId::new(actor_id)) + .map_err(to_protocol_error) +} + +/// Returns the current snapshot and session state. +#[tauri::command] +pub fn snapshot(state: State<'_, DesktopState>, session_id: String) -> Result { + lock_service(&state)? + .status(&SessionId(session_id)) + .map_err(to_protocol_error) +} + +/// Commits one typed transaction through the shared transaction engine. +#[tauri::command] +pub fn commit( + state: State<'_, DesktopState>, session_id: String, transaction: TransactionDraft, +) -> Result { + lock_service(&state)? + .commit(&SessionId(session_id), transaction) + .map_err(to_protocol_error) +} + +/// Compensates the latest transaction for the session actor. +#[tauri::command] +pub fn undo( + state: State<'_, DesktopState>, session_id: String, actor_id: String, +) -> Result { + let session_id = SessionId(session_id); + let actor_id = ActorId::new(actor_id); + lock_service(&state)? + .undo(&session_id, &actor_id) + .map_err(to_protocol_error) +} + +/// Reapplies the latest compensated transaction for the session actor. +#[tauri::command] +pub fn redo( + state: State<'_, DesktopState>, session_id: String, actor_id: String, +) -> Result { + let session_id = SessionId(session_id); + let actor_id = ActorId::new(actor_id); + lock_service(&state)? + .redo(&session_id, &actor_id) + .map_err(to_protocol_error) +} + +/// Saves the current session after checking the caller's causal heads. +#[tauri::command] +pub fn save( + state: State<'_, DesktopState>, session_id: String, expected_heads: Vec, +) -> Result { + lock_service(&state)? + .save(&SessionId(session_id), &expected_heads) + .map_err(to_protocol_error) +} + +/// Saves the current session at a replacement path after checking its heads. +#[tauri::command] +pub fn save_as( + state: State<'_, DesktopState>, session_id: String, path: DocumentPath, expected_heads: Vec, +) -> Result { + lock_service(&state)? + .save_as(&SessionId(session_id), path.0, &expected_heads) + .map_err(to_protocol_error) +} + +/// Queries records through the shared deterministic query implementation. +#[tauri::command] +pub fn query(state: State<'_, DesktopState>, session_id: String, query: Query) -> Result { + lock_service(&state)? + .query(&SessionId(session_id), &query) + .map_err(to_protocol_error) +} + +/// Validates the current session without changing it. +#[tauri::command] +pub fn validate(state: State<'_, DesktopState>, session_id: String) -> Result { + lock_service(&state)? + .validate(&SessionId(session_id)) + .map_err(to_protocol_error) +} + +/// Closes a session and releases its advisory file lock. +#[tauri::command] +pub fn close(state: State<'_, DesktopState>, session_id: String) -> Result<(), ProtocolError> { + lock_service(&state)? + .close(&SessionId(session_id)) + .map_err(to_protocol_error) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + use inkfinite_core::proto::{Operation, TransactionId}; + use inkfinite_core::{Origin, PageId, RecordVersion, Timestamp}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + #[test] + fn protocol_errors_keep_stale_heads_typed() { + let error = to_protocol_error(SessionError::StaleHeads); + assert_eq!(error.code, "stale_heads"); + } + + #[test] + fn command_service_supports_edit_save_reopen_undo_and_close() { + let root = test_directory(); + let path = root.join("board.inkfinite"); + let actor = ActorId::from("actor:desktop"); + let mut service = SessionService::new(); + let opened = service + .create( + &path, + DocumentId::from("document:desktop"), + actor.clone(), + Some("Canvas"), + ) + .expect("create session"); + let base_heads = opened.status.snapshot.heads.clone(); + let commit = service + .commit( + &opened.session_id, + TransactionDraft { + id: TransactionId("transaction:rename".into()), + actor_id: actor.clone(), + origin: Origin::Human, + base_heads, + description: "rename page".into(), + operations: vec![Operation::RenamePage { + page_id: PageId::from("page:document:desktop:1"), + name: "Renamed".into(), + expected_version: Some(RecordVersion(1)), + }], + timestamp: Timestamp(1), + }, + ) + .expect("commit edit"); + assert!(commit.status.dirty); + assert!(commit.status.can_undo); + + let undone = service.undo(&opened.session_id, &actor).expect("undo edit"); + assert_eq!( + undone.status.snapshot.document.pages[&PageId::from("page:document:desktop:1")].name, + "Canvas" + ); + let redone = service.redo(&opened.session_id, &actor).expect("redo edit"); + let saved = service + .save(&opened.session_id, &redone.status.snapshot.heads) + .expect("save edit"); + assert!(!saved.status.dirty); + let replacement = root.join("board-copy.inkfinite"); + let saved_as = service + .save_as(&opened.session_id, &replacement, &saved.status.snapshot.heads) + .expect("save as edit"); + assert_eq!(saved_as.status.path.0, replacement.to_string_lossy()); + assert!(path.exists()); + service.close(&opened.session_id).expect("close session"); + + let reopened = service.open(&replacement, actor.clone()).expect("reopen file"); + assert_eq!( + reopened.status.snapshot.document.pages[&PageId::from("page:document:desktop:1")].name, + "Renamed" + ); + service.close(&reopened.session_id).expect("close reopened session"); + remove_test_directory(root); + } + + fn test_directory() -> PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("inkfinite-session-test-{id}")); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("create test directory"); + path + } + + fn remove_test_directory(path: PathBuf) { + let _ = std::fs::remove_dir_all(path); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 690004e..5a1e140 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -4,9 +4,9 @@ "version": "0.1.0", "identifier": "org.stormlightlabs.inkfinite", "build": { - "beforeDevCommand": "cd ../../web && pnpm dev", + "beforeDevCommand": "cd ../web && pnpm dev", "devUrl": "http://localhost:5173", - "beforeBuildCommand": "cd ../../web && pnpm build", + "beforeBuildCommand": "cd ../web && pnpm build", "frontendDist": "../../web/build" }, "app": { diff --git a/apps/web/package.json b/apps/web/package.json index 86bddad..83a4efa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,11 +18,12 @@ "dependencies": { "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-dialog": "^2.4.2", - "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-store": "^2.4.1", "dexie": "^4.2.1", + "@inkfinite/bindings": "workspace:*", "@inkfinite/core": "workspace:*", - "@inkfinite/renderer": "workspace:*" + "@inkfinite/renderer": "workspace:*", + "@inkfinite/ui": "workspace:*" }, "devDependencies": { "@eslint/compat": "^1.4.0", diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index edf4236..dfa56b1 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -1,6 +1,6 @@ import { createInputAdapter } from "$lib/input"; import type { InputAdapter } from "$lib/input"; -import type { DesktopDocRepo } from "$lib/persistence/desktop"; +import { createDesktopPersistenceSink, type DesktopSessionRepo } from "$lib/persistence/desktop-session"; import { createPlatformRepo, detectPlatform } from "$lib/platform"; import { createBrushStore, createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; import type { BrushStore, SnapStore, StatusStore } from "$lib/status"; @@ -63,7 +63,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { }); let persistenceStatusStore = $state(fallbackStatusStore); let activeBoardId: string | null = null; - let desktopRepo: DesktopDocRepo | null = null; + let desktopRepo: DesktopSessionRepo | null = null; let removeBeforeUnload: (() => void) | null = null; let stencilPaletteOpen = $state(false); const handleResize = () => { @@ -197,7 +197,10 @@ export function createCanvasController(bindings: CanvasControllerBindings) { setActiveBoardId(boardId); applyLoadedDoc(doc); }); - const fileBrowser = new FileBrowserController(() => repo); + const fileBrowser = new FileBrowserController(() => repo, (boardId, doc) => { + setActiveBoardId(boardId); + applyLoadedDoc(doc); + }); function setHandleHover(handle: string | null) { if (handleState.hover === handle) { @@ -607,10 +610,19 @@ export function createCanvasController(bindings: CanvasControllerBindings) { onMount(async () => { if (platform === "desktop") { const desktopPlatformRepo = await createPlatformRepo(); - if (desktopPlatformRepo && "type" in desktopPlatformRepo && desktopPlatformRepo.type === "desktop") { - desktopRepo = desktopPlatformRepo.repo as DesktopDocRepo; + if (desktopPlatformRepo.desktop) { + desktopRepo = desktopPlatformRepo.desktop; repo = desktopRepo; - await desktop.refreshBoards(); + sink = createDesktopPersistenceSink(desktopRepo); + persistenceStatusStore = fallbackStatusStore; + + const boards = await desktop.refreshBoards(); + if (boards.length > 0) { + const boardId = boards[0].id; + const doc = await desktopRepo.loadDoc(boardId); + setActiveBoardId(boardId); + applyLoadedDoc(doc); + } } } else { webDb = new InkfiniteDB(); @@ -649,12 +661,21 @@ export function createCanvasController(bindings: CanvasControllerBindings) { renderer?.dispose(); inputAdapter?.dispose(); persistenceManager?.dispose(); + if (platform === "desktop") { + void sink?.flush() + .then(() => desktopRepo?.closeSession()) + .catch((error) => console.error("Failed to close desktop session", error)); + } unsubscribeMarqueeCamera(); removeBeforeUnload?.(); if (typeof window !== "undefined") { window.removeEventListener("resize", handleResize); } - fallbackStatusStore.update(() => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 })); + fallbackStatusStore.update(() => ({ + backend: platform === "desktop" ? "filesystem" : "indexeddb", + state: "saved", + pendingWrites: 0, + })); persistenceStatusStore = fallbackStatusStore; }); diff --git a/apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts b/apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts index 3e76c79..77469f2 100644 --- a/apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts +++ b/apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts @@ -7,6 +7,7 @@ import { type FileBrowserViewModel, type PersistentDocRepo, } from "@inkfinite/core"; +import type { LoadedDoc } from "@inkfinite/core"; export class FileBrowserController { open = $state(false); @@ -14,6 +15,7 @@ export class FileBrowserController { constructor( private getRepo: () => PersistentDocRepo | null, + private onLoadDoc?: (boardId: string, doc: LoadedDoc) => void, ) {} handleOpen = () => { @@ -40,7 +42,7 @@ export class FileBrowserController { if (this.vm) { this.vm = FileBrowserVM.setBoards(this.vm, boards); } else if (repo) { - this.vm = FileBrowserVM.create({ repo, boards }); + this.vm = FileBrowserVM.create({ repo: this.createBrowserRepo(repo), boards }); } } catch (error) { console.error("Failed to list boards", error); @@ -53,4 +55,16 @@ export class FileBrowserController { } return getBoardInspectorData(webDb, boardId, KNOWN_MIGRATION_IDS); }; + + private createBrowserRepo(repo: PersistentDocRepo): PersistentDocRepo { + const onLoadDoc = this.onLoadDoc; + return { + ...repo, + async openBoard(boardId) { + await repo.openBoard(boardId); + const doc = await repo.loadDoc(boardId); + onLoadDoc?.(boardId, doc); + }, + }; + } } diff --git a/apps/web/src/lib/filebrowser/FileBrowser.svelte b/apps/web/src/lib/filebrowser/FileBrowser.svelte index bdd7199..a451dc2 100644 --- a/apps/web/src/lib/filebrowser/FileBrowser.svelte +++ b/apps/web/src/lib/filebrowser/FileBrowser.svelte @@ -1,6 +1,7 @@