From 7f64530e73ca069984e39c720f8fdb54bb1fcc3e Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 27 Feb 2026 11:01:06 -0600 Subject: [PATCH] refactor: move session state to Rust --- crates/store/src/lib.rs | 367 +++++++++++++++++- crates/store/src/settings.rs | 24 ++ docs/roadmap.md | 11 +- src-tauri/src/commands.rs | 157 +++++++- src-tauri/src/lib.rs | 9 + src/__tests__/ExportDialog.test.tsx | 10 +- src/__tests__/Sidebar.test.tsx | 1 + src/__tests__/WorkspacePanel.test.tsx | 1 + src/__tests__/ports.test.ts | 85 +++- src/__tests__/stores/app.test.ts | 112 +----- .../useDocumentSessionEffects.test.tsx | 106 ++--- src/__tests__/useWorkspaceController.test.tsx | 15 +- src/hooks/app/useDocumentSessionEffects.ts | 52 +-- .../controllers/useWorkspaceController.ts | 163 +++++--- .../controllers/useWorkspaceViewController.ts | 2 + src/ports/commands.ts | 53 ++- src/ports/invoke.ts | 43 +- src/ports/types.ts | 36 +- src/state/selectors.ts | 14 +- src/state/stores/tabs.ts | 123 +----- src/state/types.ts | 13 +- src/types.ts | 2 + 22 files changed, 942 insertions(+), 457 deletions(-) diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 936f174..ff8948c 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -18,12 +18,13 @@ mod text_utils; pub use settings::StyleCheckSettings; pub use settings::UiLayoutSettings; -pub use settings::{CaptureDocRef, CaptureMode, FocusDimmingMode, GlobalCaptureSettings}; +pub use settings::{CaptureDocRef, CaptureMode, FocusDimmingMode, GlobalCaptureSettings, SessionState, SessionTab}; const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout"; const STYLE_CHECK_SETTINGS_KEY: &str = "style_check"; const GLOBAL_CAPTURE_SETTINGS_KEY: &str = "global_capture"; const LAST_OPEN_DOC_SETTINGS_KEY: &str = "last_open_doc"; +const SESSION_STATE_SETTINGS_KEY: &str = "session_state"; const README_TEMPLATE: &str = include_str!("../assets/README_TEMPLATE.md"); @@ -382,6 +383,275 @@ impl Store { Ok(()) } + fn normalize_session_state(session: &mut SessionState) { + session.tabs.retain(|tab| !tab.id.is_empty()); + + let mut seen_ids: HashSet = HashSet::new(); + session.tabs.retain(|tab| seen_ids.insert(tab.id.clone())); + + if session.tabs.is_empty() { + session.active_tab_id = None; + } else if !session + .active_tab_id + .as_ref() + .is_some_and(|active_tab_id| session.tabs.iter().any(|tab| tab.id == *active_tab_id)) + { + session.active_tab_id = Some(session.tabs[0].id.clone()); + } + + if session.next_tab_id == 0 { + session.next_tab_id = 1; + } + } + + fn session_get_locked(conn: &Connection) -> Result { + let maybe_value = conn + .query_row( + "SELECT value FROM app_settings WHERE key = ?1", + params![SESSION_STATE_SETTINGS_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| AppError::io(format!("Failed to query session state: {}", e)))?; + + let mut state = match maybe_value { + Some(value) => serde_json::from_str::(&value) + .map_err(|e| AppError::new(ErrorCode::Parse, format!("Failed to parse session state: {}", e)))?, + None => SessionState::default(), + }; + + Self::normalize_session_state(&mut state); + Ok(state) + } + + fn session_set_locked(conn: &Connection, session: &SessionState) -> Result<(), AppError> { + let session_json = serde_json::to_string(session) + .map_err(|e| AppError::new(ErrorCode::Parse, format!("Failed to serialize session state: {}", e)))?; + let updated_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO app_settings (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![SESSION_STATE_SETTINGS_KEY, session_json, updated_at], + ) + .map_err(|e| AppError::io(format!("Failed to persist session state: {}", e)))?; + + if let Some(active_tab_id) = session.active_tab_id.as_ref() + && let Some(active_tab) = session.tabs.iter().find(|tab| &tab.id == active_tab_id) + { + let doc_ref_json = serde_json::to_string(&active_tab.doc_ref).map_err(|e| { + AppError::new( + ErrorCode::Parse, + format!("Failed to serialize active document for session state: {}", e), + ) + })?; + + conn.execute( + "INSERT INTO app_settings (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![LAST_OPEN_DOC_SETTINGS_KEY, doc_ref_json, updated_at], + ) + .map_err(|e| AppError::io(format!("Failed to persist active document for session state: {}", e)))?; + + return Ok(()); + } + + conn.execute( + "DELETE FROM app_settings WHERE key = ?1", + params![LAST_OPEN_DOC_SETTINGS_KEY], + ) + .map_err(|e| AppError::io(format!("Failed to clear active document for session state: {}", e)))?; + + Ok(()) + } + + pub fn session_get(&self) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + Self::session_get_locked(&conn) + } + + pub fn session_open_tab(&self, doc_ref: CaptureDocRef, title: String) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + if let Some(existing_tab) = state.tabs.iter().find(|tab| tab.doc_ref == doc_ref) { + state.active_tab_id = Some(existing_tab.id.clone()); + Self::session_set_locked(&conn, &state)?; + return Ok(state); + } + + let tab_id = format!("tab-{}", state.next_tab_id); + state.next_tab_id += 1; + state + .tabs + .push(SessionTab { id: tab_id.clone(), doc_ref, title, is_modified: false }); + state.active_tab_id = Some(tab_id); + + Self::session_set_locked(&conn, &state)?; + Ok(state) + } + + pub fn session_select_tab(&self, tab_id: &str) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + if state.tabs.iter().any(|tab| tab.id == tab_id) { + state.active_tab_id = Some(tab_id.to_string()); + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + + pub fn session_close_tab(&self, tab_id: &str) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + let closed_tab_index = state.tabs.iter().position(|tab| tab.id == tab_id); + if let Some(closed_tab_index) = closed_tab_index { + state.tabs.remove(closed_tab_index); + + if state.tabs.is_empty() { + state.active_tab_id = None; + } else if state.active_tab_id.as_deref() == Some(tab_id) { + let next_index = closed_tab_index.min(state.tabs.len() - 1); + state.active_tab_id = Some(state.tabs[next_index].id.clone()); + } + + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + + pub fn session_reorder_tabs(&self, tab_ids: &[String]) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + if state.tabs.is_empty() { + return Ok(state); + } + + let mut remaining = state.tabs.clone(); + let mut reordered = Vec::with_capacity(remaining.len()); + + for tab_id in tab_ids { + if let Some(index) = remaining.iter().position(|tab| tab.id == *tab_id) { + reordered.push(remaining.remove(index)); + } + } + + reordered.extend(remaining); + state.tabs = reordered; + + Self::normalize_session_state(&mut state); + Self::session_set_locked(&conn, &state)?; + Ok(state) + } + + pub fn session_mark_tab_modified(&self, tab_id: &str, is_modified: bool) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + if let Some(tab) = state.tabs.iter_mut().find(|tab| tab.id == tab_id) + && tab.is_modified != is_modified + { + tab.is_modified = is_modified; + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + + pub fn session_update_tab_doc( + &self, location_id: i64, old_rel_path: &str, new_doc_ref: CaptureDocRef, title: String, + ) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + let mut changed = false; + for tab in &mut state.tabs { + if tab.doc_ref.location_id == location_id && tab.doc_ref.rel_path == old_rel_path { + tab.doc_ref = new_doc_ref.clone(); + tab.title = title.clone(); + changed = true; + } + } + + if changed { + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + + pub fn session_drop_doc(&self, location_id: i64, rel_path: &str) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + let original_len = state.tabs.len(); + state + .tabs + .retain(|tab| !(tab.doc_ref.location_id == location_id && tab.doc_ref.rel_path == rel_path)); + + if state.tabs.len() != original_len { + Self::normalize_session_state(&mut state); + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + + pub fn session_prune_locations(&self, valid_location_ids: &HashSet) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + let mut state = Self::session_get_locked(&conn)?; + + let original_len = state.tabs.len(); + state + .tabs + .retain(|tab| valid_location_ids.contains(&tab.doc_ref.location_id)); + + if state.tabs.len() != original_len { + Self::normalize_session_state(&mut state); + Self::session_set_locked(&conn, &state)?; + } + + Ok(state) + } + /// Adds a new location pub fn location_add(&self, name: String, root_path: PathBuf) -> Result { let path_str = root_path.to_string_lossy().to_string(); @@ -2166,6 +2436,101 @@ mod tests { assert!(loaded.is_none()); } + #[test] + fn test_session_open_select_close_round_trip() { + let (store, _temp) = create_test_store(); + let first_doc = CaptureDocRef { location_id: 1, rel_path: "notes/a.md".to_string() }; + let second_doc = CaptureDocRef { location_id: 1, rel_path: "notes/b.md".to_string() }; + + let opened = store.session_open_tab(first_doc.clone(), "A".to_string()).unwrap(); + assert_eq!(opened.tabs.len(), 1); + let first_tab_id = opened.tabs[0].id.clone(); + assert_eq!(opened.active_tab_id, Some(first_tab_id.clone())); + + let opened_again = store.session_open_tab(first_doc.clone(), "A".to_string()).unwrap(); + assert_eq!(opened_again.tabs.len(), 1); + assert_eq!(opened_again.active_tab_id, Some(first_tab_id.clone())); + + let with_second = store.session_open_tab(second_doc.clone(), "B".to_string()).unwrap(); + assert_eq!(with_second.tabs.len(), 2); + let second_tab_id = with_second.tabs[1].id.clone(); + assert_eq!(with_second.active_tab_id, Some(second_tab_id.clone())); + + let selected = store.session_select_tab(&first_tab_id).unwrap(); + assert_eq!(selected.active_tab_id, Some(first_tab_id.clone())); + + let closed = store.session_close_tab(&first_tab_id).unwrap(); + assert_eq!(closed.tabs.len(), 1); + assert_eq!(closed.tabs[0].doc_ref, second_doc); + assert_eq!(closed.active_tab_id, Some(second_tab_id)); + } + + #[test] + fn test_session_reorder_mark_and_update_doc() { + let (store, _temp) = create_test_store(); + let first_doc = CaptureDocRef { location_id: 7, rel_path: "first.md".to_string() }; + let second_doc = CaptureDocRef { location_id: 7, rel_path: "second.md".to_string() }; + + let first = store.session_open_tab(first_doc.clone(), "First".to_string()).unwrap(); + let first_tab_id = first.tabs[0].id.clone(); + let second = store + .session_open_tab(second_doc.clone(), "Second".to_string()) + .unwrap(); + let second_tab_id = second.tabs[1].id.clone(); + + let reordered = store + .session_reorder_tabs(&[second_tab_id.clone(), first_tab_id.clone()]) + .unwrap(); + assert_eq!(reordered.tabs[0].id, second_tab_id); + assert_eq!(reordered.tabs[1].id, first_tab_id.clone()); + + let modified = store.session_mark_tab_modified(&first_tab_id, true).unwrap(); + let marked_tab = modified.tabs.iter().find(|tab| tab.id == first_tab_id).unwrap(); + assert!(marked_tab.is_modified); + + let updated = store + .session_update_tab_doc( + 7, + "first.md", + CaptureDocRef { location_id: 7, rel_path: "renamed.md".to_string() }, + "Renamed".to_string(), + ) + .unwrap(); + let renamed_tab = updated.tabs.iter().find(|tab| tab.id == first_tab_id).unwrap(); + assert_eq!(renamed_tab.doc_ref.rel_path, "renamed.md"); + assert_eq!(renamed_tab.title, "Renamed"); + } + + #[test] + fn test_session_prunes_locations_and_syncs_last_open_doc() { + let (store, _temp) = create_test_store(); + + store + .session_open_tab( + CaptureDocRef { location_id: 10, rel_path: "keep.md".to_string() }, + "Keep".to_string(), + ) + .unwrap(); + store + .session_open_tab( + CaptureDocRef { location_id: 20, rel_path: "drop.md".to_string() }, + "Drop".to_string(), + ) + .unwrap(); + + let mut valid_locations = HashSet::new(); + valid_locations.insert(10); + let pruned = store.session_prune_locations(&valid_locations).unwrap(); + + assert_eq!(pruned.tabs.len(), 1); + assert_eq!(pruned.tabs[0].doc_ref.location_id, 10); + assert_eq!(store.last_open_doc_get().unwrap(), Some(pruned.tabs[0].doc_ref.clone())); + + let dropped = store.session_drop_doc(10, "keep.md").unwrap(); + assert!(dropped.tabs.is_empty()); + assert!(store.last_open_doc_get().unwrap().is_none()); + } + #[test] fn test_readme_created_in_new_location_by_default() { let (store, _temp) = create_test_store(); diff --git a/crates/store/src/settings.rs b/crates/store/src/settings.rs index 216236b..471dc34 100644 --- a/crates/store/src/settings.rs +++ b/crates/store/src/settings.rs @@ -148,6 +148,30 @@ pub struct CaptureDocRef { pub rel_path: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionTab { + pub id: String, + pub doc_ref: CaptureDocRef, + pub title: String, + pub is_modified: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionState { + #[serde(default)] + pub active_tab_id: Option, + #[serde(default)] + pub tabs: Vec, + #[serde(default)] + pub next_tab_id: u64, +} + +impl Default for SessionState { + fn default() -> Self { + Self { active_tab_id: None, tabs: Vec::new(), next_tab_id: 1 } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GlobalCaptureSettings { #[serde(default = "default_true")] diff --git a/docs/roadmap.md b/docs/roadmap.md index 229478e..6a88ec5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -76,18 +76,15 @@ Migrate core application state and heavy computation to the Rust backend to redu ### Tasks -1. **Session & Tab Management** - - Shift `TabsStore` logic to a persistent Rust-based session manager - - Support session persistence across application restarts and potential multi-window sync -2. **Reactive File System** +1. **Reactive File System** - Leverage `RecommendedWatcher` to eliminate the need for manual frontend file list updates - Implement event-driven UI updates based on backend file system events -3. **High-Performance Analysis** +2. **High-Performance Analysis** - Move `PatternMatcher` and `StyleCheck` logic to Rust using the `aho-corasick` crate - Offload heavy multi-pattern matching from the JS main thread -4. **Unified Metadata Extraction** +3. **Unified Metadata Extraction** - Calculate document metadata (word counts, outlines) during the `markdown_render` pass in Rust -5. **Architectural Hardening** +4. **Architectural Hardening** - Simplify and unify `CommandResult` and `AppError` patterns across all Tauri commands ## Hardening diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ab1651b..7d58be4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use super::capture; use super::locations::*; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; @@ -202,6 +202,161 @@ pub fn session_last_doc_set( } } +#[tauri::command] +pub fn session_get(state: State<'_, AppState>) -> Result> { + tracing::debug!("Loading persisted session state"); + + match state.store.session_get() { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to load session state: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_open_tab( + state: State<'_, AppState>, doc_ref: writer_store::CaptureDocRef, title: String, +) -> Result> { + tracing::debug!( + "Opening session tab: location_id={}, rel_path={}", + doc_ref.location_id, + doc_ref.rel_path + ); + + match state.store.session_open_tab(doc_ref, title) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to open session tab: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_select_tab( + state: State<'_, AppState>, tab_id: String, +) -> Result> { + tracing::debug!("Selecting session tab: {}", tab_id); + + match state.store.session_select_tab(&tab_id) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to select session tab: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_close_tab( + state: State<'_, AppState>, tab_id: String, +) -> Result> { + tracing::debug!("Closing session tab: {}", tab_id); + + match state.store.session_close_tab(&tab_id) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to close session tab: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_reorder_tabs( + state: State<'_, AppState>, tab_ids: Vec, +) -> Result> { + tracing::debug!("Reordering session tabs: count={}", tab_ids.len()); + + match state.store.session_reorder_tabs(&tab_ids) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to reorder session tabs: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_mark_tab_modified( + state: State<'_, AppState>, tab_id: String, is_modified: bool, +) -> Result> { + tracing::debug!( + "Marking session tab modified: tab_id={}, is_modified={}", + tab_id, + is_modified + ); + + match state.store.session_mark_tab_modified(&tab_id, is_modified) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to mark session tab modified: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_update_tab_doc( + state: State<'_, AppState>, location_id: i64, old_rel_path: String, new_doc_ref: writer_store::CaptureDocRef, + title: String, +) -> Result> { + tracing::debug!( + "Updating session tab document: location_id={}, old_rel_path={}, new_rel_path={}", + location_id, + old_rel_path, + new_doc_ref.rel_path + ); + + match state + .store + .session_update_tab_doc(location_id, &old_rel_path, new_doc_ref, title) + { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to update session tab document: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_drop_doc( + state: State<'_, AppState>, location_id: i64, rel_path: String, +) -> Result> { + tracing::debug!( + "Dropping document from session tabs: location_id={}, rel_path={}", + location_id, + rel_path + ); + + match state.store.session_drop_doc(location_id, &rel_path) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to drop document from session tabs: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn session_prune_locations( + state: State<'_, AppState>, valid_location_ids: Vec, +) -> Result> { + tracing::debug!("Pruning session tabs by locations: count={}", valid_location_ids.len()); + + let valid_ids: HashSet = valid_location_ids.into_iter().collect(); + match state.store.session_prune_locations(&valid_ids) { + Ok(session) => Ok(CommandResult::ok(session)), + Err(e) => { + tracing::error!("Failed to prune session tabs by locations: {}", e); + Ok(CommandResult::err(e)) + } + } +} + /// Lists documents in a location #[tauri::command] pub fn doc_list( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5cc6f3..5ec7055 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -81,6 +81,15 @@ pub fn run() { cmd::markdown_render_for_pdf, cmd::ui_layout_get, cmd::ui_layout_set, + cmd::session_get, + cmd::session_open_tab, + cmd::session_select_tab, + cmd::session_close_tab, + cmd::session_reorder_tabs, + cmd::session_mark_tab_modified, + cmd::session_update_tab_doc, + cmd::session_drop_doc, + cmd::session_prune_locations, cmd::session_last_doc_get, cmd::session_last_doc_set, cmd::style_check_get, diff --git a/src/__tests__/ExportDialog.test.tsx b/src/__tests__/ExportDialog.test.tsx index 34b65dc..5ce5e46 100644 --- a/src/__tests__/ExportDialog.test.tsx +++ b/src/__tests__/ExportDialog.test.tsx @@ -207,7 +207,15 @@ describe("PdfExportDialog", () => { word_count: 100, updated_at: "2024-01-01", }]); - useAppStore.getState().openDocumentTab({ location_id: 1, rel_path: "test.md" }, "My Document"); + useAppStore.getState().applySessionState({ + activeTabId: "tab-1", + tabs: [{ + id: "tab-1", + docRef: { location_id: 1, rel_path: "test.md" }, + title: "My Document", + isModified: false, + }], + }); renderExportDialog(); expect(screen.getByText("My Document")).toBeInTheDocument(); diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index 965be17..a6d93b7 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -33,6 +33,7 @@ const createWorkspaceControllerState = ( locationDocuments: [], sidebarFilter: "", isSidebarLoading: false, + isSessionHydrated: true, refreshingLocationId: undefined, sidebarRefreshReason: null, tabs: [], diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 0cdb49e..d56325b 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -139,6 +139,7 @@ const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { locationDocuments: [], sidebarFilter: "", isSidebarLoading: false, + isSessionHydrated: true, refreshingLocationId: undefined, sidebarRefreshReason: null, tabs: [], diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index ca5aa3d..1fc6b51 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -26,8 +26,15 @@ import { renderMarkdownForPdf, runCmd, searchDocuments, - sessionLastDocGet, - sessionLastDocSet, + sessionCloseTab, + sessionDropDoc, + sessionGet, + sessionMarkTabModified, + sessionOpenTab, + sessionPruneLocations, + sessionReorderTabs, + sessionSelectTab, + sessionUpdateTabDoc, startWatch, stopWatch, SubscriptionManager, @@ -840,37 +847,81 @@ describe("ui layout Commands", () => { }); describe("session Commands", () => { - describe(sessionLastDocGet, () => { + describe(sessionGet, () => { it("should create command with empty payload", () => { const onOk = vi.fn(); const onErr = vi.fn(); - const cmd = sessionLastDocGet(onOk, onErr) as InvokeCmd; + const cmd = sessionGet(onOk, onErr) as InvokeCmd; expect(cmd.type).toBe("Invoke"); - expect(cmd.command).toBe("session_last_doc_get"); + expect(cmd.command).toBe("session_get"); expect(cmd.payload).toStrictEqual({}); }); }); - describe(sessionLastDocSet, () => { - it("should create command with docRef payload", () => { + describe(sessionOpenTab, () => { + it("should create command with open payload", () => { const onOk = vi.fn(); const onErr = vi.fn(); - const cmd = sessionLastDocSet({ location_id: 7, rel_path: "notes/start.md" }, onOk, onErr) as InvokeCmd; + const cmd = sessionOpenTab({ location_id: 7, rel_path: "notes/start.md" }, "Start", onOk, onErr) as InvokeCmd; expect(cmd.type).toBe("Invoke"); - expect(cmd.command).toBe("session_last_doc_set"); - expect(cmd.payload).toStrictEqual({ docRef: { location_id: 7, rel_path: "notes/start.md" } }); + expect(cmd.command).toBe("session_open_tab"); + expect(cmd.payload).toStrictEqual({ docRef: { location_id: 7, rel_path: "notes/start.md" }, title: "Start" }); }); + }); - it("should support clearing the persisted docRef", () => { - const onOk = vi.fn(); - const onErr = vi.fn(); - const cmd = sessionLastDocSet(null, onOk, onErr) as InvokeCmd; + it("should create tab-id session commands", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); - expect(cmd.type).toBe("Invoke"); - expect(cmd.command).toBe("session_last_doc_set"); - expect(cmd.payload).toStrictEqual({ docRef: null }); + expect(sessionSelectTab("tab-1", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_select_tab", + payload: { tabId: "tab-1" }, + }); + expect(sessionCloseTab("tab-1", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_close_tab", + payload: { tabId: "tab-1" }, + }); + }); + + it("should create reorder and modified commands", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + expect(sessionReorderTabs(["tab-2", "tab-1"], onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_reorder_tabs", + payload: { tabIds: ["tab-2", "tab-1"] }, + }); + expect(sessionMarkTabModified("tab-2", true, onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_mark_tab_modified", + payload: { tabId: "tab-2", isModified: true }, + }); + }); + + it("should create doc-linked session commands", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + expect(sessionUpdateTabDoc(1, "old.md", { location_id: 1, rel_path: "new.md" }, "New", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_update_tab_doc", + payload: { locationId: 1, oldRelPath: "old.md", newDocRef: { location_id: 1, rel_path: "new.md" }, title: "New" }, + }); + + expect(sessionDropDoc(1, "new.md", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_drop_doc", + payload: { locationId: 1, relPath: "new.md" }, + }); + expect(sessionPruneLocations([1, 2], onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "session_prune_locations", + payload: { validLocationIds: [1, 2] }, }); }); }); diff --git a/src/__tests__/stores/app.test.ts b/src/__tests__/stores/app.test.ts index d066e3c..63e3d68 100644 --- a/src/__tests__/stores/app.test.ts +++ b/src/__tests__/stores/app.test.ts @@ -33,62 +33,6 @@ describe("appStore", () => { expect(useAppStore.getState().selectedLocationId).toBe(10); }); - it("opens and reuses document tabs", () => { - const state = useAppStore.getState(); - const firstOpen = state.openDocumentTab({ location_id: 1, rel_path: "notes/a.md" }, "A"); - const secondOpen = useAppStore.getState().openDocumentTab({ location_id: 1, rel_path: "notes/a.md" }, "A"); - - expect(firstOpen.didCreateTab).toBeTruthy(); - expect(secondOpen.didCreateTab).toBeFalsy(); - expect(useAppStore.getState().tabs).toHaveLength(1); - expect(useAppStore.getState().activeTabId).toBe(firstOpen.tabId); - }); - - it("closing the active tab activates an adjacent tab", () => { - const store = useAppStore.getState(); - const first = store.openDocumentTab({ location_id: 1, rel_path: "a.md" }, "A"); - const second = useAppStore.getState().openDocumentTab({ location_id: 1, rel_path: "b.md" }, "B"); - - useAppStore.getState().selectTab(first.tabId); - - const nextDocRef = useAppStore.getState().closeTab(first.tabId); - expect(nextDocRef).toStrictEqual({ location_id: 1, rel_path: "b.md" }); - expect(useAppStore.getState().activeTabId).toBe(second.tabId); - expect(useAppStore.getState().tabs).toHaveLength(1); - }); - - it("removing a location clears selected and active state tied to that location", () => { - const store = useAppStore.getState(); - - store.setLocations([{ id: 1, name: "A", root_path: "/a", added_at: "2024-01-01" }, { - id: 2, - name: "B", - root_path: "/b", - added_at: "2024-01-01", - }]); - - store.openDocumentTab({ location_id: 1, rel_path: "a.md" }, "A"); - useAppStore.getState().removeLocation(1); - - expect(useAppStore.getState().locations.map((location) => location.id)).toStrictEqual([2]); - expect(useAppStore.getState().selectedLocationId).toBeUndefined(); - expect(useAppStore.getState().activeTabId).toBeNull(); - expect(useAppStore.getState().tabs).toStrictEqual([]); - }); - - it("marks only the active tab as modified", () => { - const store = useAppStore.getState(); - const first = store.openDocumentTab({ location_id: 1, rel_path: "a.md" }, "A"); - const second = useAppStore.getState().openDocumentTab({ location_id: 1, rel_path: "b.md" }, "B"); - - useAppStore.getState().selectTab(first.tabId); - useAppStore.getState().markActiveTabModified(true); - - const { tabs } = useAppStore.getState(); - expect(tabs.find((tab) => tab.id === first.tabId)?.isModified).toBeTruthy(); - expect(tabs.find((tab) => tab.id === second.tabId)?.isModified).toBeFalsy(); - }); - it("keeps selected location when locations refresh and selected id still exists", () => { const store = useAppStore.getState(); @@ -121,34 +65,6 @@ describe("appStore", () => { expect(useAppStore.getState().selectedDocPath).toBe("notes/new.md"); }); - it("closing an inactive tab leaves the active tab unchanged", () => { - const store = useAppStore.getState(); - const first = store.openDocumentTab({ location_id: 1, rel_path: "a.md" }, "A"); - const second = useAppStore.getState().openDocumentTab({ location_id: 1, rel_path: "b.md" }, "B"); - const closedResult = useAppStore.getState().closeTab(first.tabId); - - expect(closedResult).toBeNull(); - expect(useAppStore.getState().tabs.map((tab) => tab.id)).toStrictEqual([second.tabId]); - expect(useAppStore.getState().activeTabId).toBe(second.tabId); - }); - - it("closing the final active tab clears tab and selection state", () => { - const store = useAppStore.getState(); - const only = store.openDocumentTab({ location_id: 1, rel_path: "only.md" }, "Only"); - - const nextRef = useAppStore.getState().closeTab(only.tabId); - - expect(nextRef).toBeNull(); - expect(useAppStore.getState().tabs).toStrictEqual([]); - expect(useAppStore.getState().activeTabId).toBeNull(); - expect(useAppStore.getState().selectedDocPath).toBeUndefined(); - }); - - it("markActiveTabModified is a no-op when no active tab exists", () => { - useAppStore.getState().markActiveTabModified(true); - expect(useAppStore.getState().tabs).toStrictEqual([]); - }); - it("focused layout hooks expose and update layout state", () => { const { result: chromeState } = renderHook(() => useLayoutChromeState()); const { result: chromeActions } = renderHook(() => useLayoutChromeActions()); @@ -259,28 +175,26 @@ describe("appStore", () => { expect(locationsState.current.locations).toStrictEqual([]); }); - it("tabs selector hooks expose and update tab state", () => { + it("tabs selector hooks apply backend session state", () => { const { result: tabsState } = renderHook(() => useTabsState()); const { result: tabsActions } = renderHook(() => useTabsActions()); - let firstTabId = ""; - let secondTabId = ""; - act(() => { - firstTabId = tabsActions.current.openDocumentTab({ location_id: 1, rel_path: "first.md" }, "First").tabId; - secondTabId = tabsActions.current.openDocumentTab({ location_id: 1, rel_path: "second.md" }, "Second").tabId; - tabsActions.current.selectTab(firstTabId); - const currentTabs = useAppStore.getState().tabs; - tabsActions.current.reorderTabs([{ ...currentTabs.find((tab) => tab.id === secondTabId)!, isModified: false }, { - ...currentTabs.find((tab) => tab.id === firstTabId)!, - isModified: false, - }]); + tabsActions.current.applySessionState({ + activeTabId: "tab-2", + tabs: [{ id: "tab-1", docRef: { location_id: 1, rel_path: "first.md" }, title: "First", isModified: false }, { + id: "tab-2", + docRef: { location_id: 1, rel_path: "second.md" }, + title: "Second", + isModified: true, + }], + }); }); expect(tabsState.current.tabs).toHaveLength(2); - expect(tabsState.current.activeTabId).toBe(firstTabId); - expect(tabsState.current.tabs[0].id).toBe(secondTabId); - expect(tabsState.current.tabs[1].id).toBe(firstTabId); + expect(tabsState.current.activeTabId).toBe("tab-2"); + expect(tabsState.current.isSessionHydrated).toBe(true); + expect(useAppStore.getState().selectedDocPath).toBe("second.md"); }); it("should update typewriter scrolling setting", () => { diff --git a/src/__tests__/useDocumentSessionEffects.test.tsx b/src/__tests__/useDocumentSessionEffects.test.tsx index 50fd413..fcd8ccf 100644 --- a/src/__tests__/useDocumentSessionEffects.test.tsx +++ b/src/__tests__/useDocumentSessionEffects.test.tsx @@ -1,23 +1,15 @@ import { useDocumentSessionEffects } from "$hooks/app/useDocumentSessionEffects"; -import { docExists, runCmd, sessionLastDocGet, sessionLastDocSet } from "$ports"; -import type { AppError, DocRef, LocationDescriptor } from "$types"; -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock( - "$ports", - () => ({ runCmd: vi.fn(async () => {}), sessionLastDocGet: vi.fn(), docExists: vi.fn(), sessionLastDocSet: vi.fn() }), -); +import type { DocRef, LocationDescriptor } from "$types"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; type UseDocumentSessionEffectsArgs = Parameters[0]; const LOCATION: LocationDescriptor = { id: 1, name: "Workspace", root_path: "/workspace", added_at: "2024-01-01" }; -let sessionLastDocGetOnOk: ((docRef: DocRef | null) => void) | null = null; -let docExistsOnOk: ((exists: boolean) => void) | null = null; - const createArgs = (overrides: Partial = {}): UseDocumentSessionEffectsArgs => ({ isSidebarLoading: false, + isSessionHydrated: true, locations: [LOCATION], selectedLocationId: LOCATION.id, tabs: [], @@ -31,86 +23,34 @@ const createArgs = (overrides: Partial = {}): Use }); describe("useDocumentSessionEffects", () => { - beforeEach(() => { - vi.clearAllMocks(); - sessionLastDocGetOnOk = null; - docExistsOnOk = null; - - vi.mocked(sessionLastDocGet).mockImplementation( - (onOk: (docRef: DocRef | null) => void, _onErr: (error: AppError) => void) => { - sessionLastDocGetOnOk = onOk; - return { type: "None" } as never; - }, - ); - - vi.mocked(docExists).mockImplementation( - (_locationId: number, _relPath: string, onOk: (exists: boolean) => void, _onErr: (error: AppError) => void) => { - docExistsOnOk = onOk; - return { type: "None" } as never; - }, - ); - - vi.mocked(sessionLastDocSet).mockImplementation(( - _docRef: DocRef | null, - _onOk: (value: boolean) => void, - _onErr: (error: AppError) => void, - ) => ({ type: "None" } as never)); - - vi.mocked(runCmd).mockResolvedValue(); - }); - - it("does not create a new document while restoring a previously opened one", () => { - const handleSelectDocument = vi.fn(); + it("waits for session hydration before creating a startup draft", () => { const handleNewDocument = vi.fn(); - - renderHook(() => useDocumentSessionEffects(createArgs({ handleSelectDocument, handleNewDocument }))); - - expect(sessionLastDocGet).toHaveBeenCalledOnce(); - expect(handleNewDocument).not.toHaveBeenCalled(); - - act(() => { - sessionLastDocGetOnOk?.({ location_id: LOCATION.id, rel_path: "notes/last.md" }); - }); - - expect(docExists).toHaveBeenCalledWith(LOCATION.id, "notes/last.md", expect.any(Function), expect.any(Function)); - - act(() => { - docExistsOnOk?.(true); - }); - - expect(handleSelectDocument).toHaveBeenCalledWith(LOCATION.id, "notes/last.md"); + renderHook(() => useDocumentSessionEffects(createArgs({ isSessionHydrated: false, handleNewDocument }))); expect(handleNewDocument).not.toHaveBeenCalled(); }); - it("creates a new document when no previous document exists", () => { + it("creates a new draft on startup when no tabs are restored", () => { const handleNewDocument = vi.fn(); - renderHook(() => useDocumentSessionEffects(createArgs({ handleNewDocument }))); - - act(() => { - sessionLastDocGetOnOk?.(null); - }); - - expect(handleNewDocument).toHaveBeenCalledOnce(); + expect(handleNewDocument).toHaveBeenCalled(); expect(handleNewDocument).toHaveBeenCalledWith(LOCATION.id); }); - it("creates a new document when the previous document is missing", () => { - const handleSelectDocument = vi.fn(); - const handleNewDocument = vi.fn(); - - renderHook(() => useDocumentSessionEffects(createArgs({ handleSelectDocument, handleNewDocument }))); - - act(() => { - sessionLastDocGetOnOk?.({ location_id: LOCATION.id, rel_path: "notes/missing.md" }); - }); - - act(() => { - docExistsOnOk?.(false); - }); + it("opens the active document when one is selected", () => { + const openDoc = vi.fn(); + const activeDocRef: DocRef = { location_id: LOCATION.id, rel_path: "notes/today.md" }; + + renderHook(() => + useDocumentSessionEffects( + createArgs({ + tabs: [{ id: "tab-1", docRef: activeDocRef, title: "Today", isModified: false }], + activeTab: { id: "tab-1", docRef: activeDocRef, title: "Today", isModified: false }, + activeDocRef, + openDoc, + }), + ) + ); - expect(handleSelectDocument).not.toHaveBeenCalled(); - expect(handleNewDocument).toHaveBeenCalledOnce(); - expect(handleNewDocument).toHaveBeenCalledWith(LOCATION.id); + expect(openDoc).toHaveBeenCalledWith(activeDocRef); }); }); diff --git a/src/__tests__/useWorkspaceController.test.tsx b/src/__tests__/useWorkspaceController.test.tsx index 6e0828d..fdf8ace 100644 --- a/src/__tests__/useWorkspaceController.test.tsx +++ b/src/__tests__/useWorkspaceController.test.tsx @@ -1,5 +1,5 @@ import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; -import { docList, runCmd } from "$ports"; +import { docList, runCmd, sessionGet, sessionPruneLocations } from "$ports"; import { resetAppStore, useAppStore } from "$state/stores/app"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +16,17 @@ vi.mock( docRename: vi.fn(() => ({ type: "None" })), locationAddViaDialog: vi.fn(() => ({ type: "None" })), locationRemove: vi.fn(() => ({ type: "None" })), + sessionGet: vi.fn((_onOk: (session: { tabs: unknown[]; activeTabId: string | null }) => void) => ({ + type: "None", + })), + sessionPruneLocations: vi.fn(() => ({ type: "None" })), + sessionOpenTab: vi.fn(() => ({ type: "None" })), + sessionSelectTab: vi.fn(() => ({ type: "None" })), + sessionCloseTab: vi.fn(() => ({ type: "None" })), + sessionReorderTabs: vi.fn(() => ({ type: "None" })), + sessionMarkTabModified: vi.fn(() => ({ type: "None" })), + sessionUpdateTabDoc: vi.fn(() => ({ type: "None" })), + sessionDropDoc: vi.fn(() => ({ type: "None" })), }), ); @@ -40,6 +51,8 @@ describe("useWorkspaceController", () => { }); expect(createdRef).toMatchObject({ location_id: 1 }); + expect(sessionGet).toHaveBeenCalledOnce(); + expect(sessionPruneLocations).not.toHaveBeenCalled(); }); it("ignores non-numeric locationId values in handleRefreshSidebar", () => { diff --git a/src/hooks/app/useDocumentSessionEffects.ts b/src/hooks/app/useDocumentSessionEffects.ts index c9a6a43..55c9b45 100644 --- a/src/hooks/app/useDocumentSessionEffects.ts +++ b/src/hooks/app/useDocumentSessionEffects.ts @@ -1,9 +1,9 @@ -import { docExists, runCmd, sessionLastDocGet, sessionLastDocSet } from "$ports"; import type { DocRef, LocationDescriptor, Tab } from "$types"; import { useEffect, useRef } from "react"; type UseDocumentSessionEffectsArgs = { isSidebarLoading: boolean; + isSessionHydrated: boolean; locations: LocationDescriptor[]; selectedLocationId?: number; tabs: Tab[]; @@ -18,6 +18,7 @@ type UseDocumentSessionEffectsArgs = { export function useDocumentSessionEffects( { isSidebarLoading, + isSessionHydrated, locations, selectedLocationId, tabs, @@ -37,48 +38,17 @@ export function useDocumentSessionEffects( return; } - if (isSidebarLoading || locations.length === 0 || tabs.length > 0) { + if (!isSessionHydrated || isSidebarLoading || locations.length === 0) { return; } startupDocumentReadyRef.current = true; + startupDocumentRestoredRef.current = true; - const completeStartupRestore = () => { - startupDocumentRestoredRef.current = true; - }; - - const fallbackToBlankDraft = () => { - completeStartupRestore(); + if (tabs.length === 0) { handleNewDocument(selectedLocationId ?? locations[0]?.id); - }; - - void runCmd(sessionLastDocGet((docRef) => { - if (!docRef) { - fallbackToBlankDraft(); - return; - } - - const locationExists = locations.some((location) => location.id === docRef.location_id); - if (!locationExists) { - fallbackToBlankDraft(); - return; - } - - void runCmd(docExists(docRef.location_id, docRef.rel_path, (exists) => { - if (exists) { - completeStartupRestore(); - handleSelectDocument(docRef.location_id, docRef.rel_path); - return; - } - - fallbackToBlankDraft(); - }, () => { - fallbackToBlankDraft(); - })); - }, () => { - fallbackToBlankDraft(); - })); - }, [isSidebarLoading, locations, selectedLocationId, tabs.length, handleSelectDocument, handleNewDocument]); + } + }, [isSessionHydrated, isSidebarLoading, locations, selectedLocationId, tabs.length, handleNewDocument]); useEffect(() => { if (!activeDocRef) { @@ -105,12 +75,4 @@ export function useDocumentSessionEffects( handleNewDocument(selectedLocationId); }, [activeTab, documentsCount, handleNewDocument, handleSelectDocument, isSidebarLoading, selectedLocationId, tabs]); - - useEffect(() => { - if (!startupDocumentRestoredRef.current) { - return; - } - - void runCmd(sessionLastDocSet(activeDocRef, () => {}, () => {})); - }, [activeDocRef]); } diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index 6e34c7c..c867608 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -7,6 +7,15 @@ import { locationAddViaDialog, locationRemove, runCmd, + sessionCloseTab, + sessionDropDoc, + sessionGet, + sessionMarkTabModified, + sessionOpenTab, + sessionPruneLocations, + sessionReorderTabs, + sessionSelectTab, + sessionUpdateTabDoc, } from "$ports"; import { useTabsActions, @@ -16,14 +25,13 @@ import { useWorkspaceLocationsActions, useWorkspaceLocationsState, } from "$state/selectors"; -import { useTabsStore } from "$state/stores/tabs"; import { useWorkspaceStore } from "$state/stores/workspace"; import type { SidebarRefreshReason } from "$state/types"; -import type { AppError, DocMeta, DocRef } from "$types"; +import type { AppError, DocMeta, DocRef, SessionState, Tab } from "$types"; import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; import { f } from "$utils/serialize"; import * as logger from "@tauri-apps/plugin-log"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; const TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS = 120; @@ -61,10 +69,32 @@ export function useWorkspaceController() { useWorkspaceDocumentsState(); const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); const { setSidebarFilter, setSelectedLocation, addLocation, removeLocation } = useWorkspaceLocationsActions(); - const { tabs, activeTabId } = useTabsState(); - const { openDocumentTab, selectTab, closeTab, reorderTabs, markActiveTabModified } = useTabsActions(); + const { tabs, activeTabId, isSessionHydrated } = useTabsState(); + const { applySessionState } = useTabsActions(); const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [activeTabId, tabs]); + const applySession = useCallback((session: SessionState) => { + applySessionState(session); + }, [applySessionState]); + + useEffect(() => { + void runCmd(sessionGet(applySession, (error) => { + logger.error(f("Failed to load session state", { error })); + applySession({ tabs: [], activeTabId: null }); + })); + }, [applySession]); + + useEffect(() => { + if (!isSessionHydrated) { + return; + } + + const validLocationIds = locations.map((location) => location.id); + void runCmd(sessionPruneLocations(validLocationIds, applySession, (error) => { + logger.error(f("Failed to prune session tabs by location", { error, validLocationIds })); + })); + }, [locations, isSessionHydrated, applySession]); + const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], @@ -88,28 +118,57 @@ export function useWorkspaceController() { })); }, [removeLocation]); + const openTab = useCallback((docRef: DocRef, title: string) => { + void runCmd(sessionOpenTab(docRef, title, applySession, (error) => { + logger.error(f("Failed to open session tab", { docRef, title, error })); + })); + }, [applySession]); + const handleSelectDocument = useCallback((locationId: number, path: string) => { const docTitle = useWorkspaceStore.getState().documents.find((doc) => doc.location_id === locationId && doc.rel_path === path )?.title; const title = docTitle || path.split("/").pop() || "Untitled"; - const docRef = { location_id: locationId, rel_path: path }; - openDocumentTab(docRef, title); - }, [openDocumentTab]); + openTab({ location_id: locationId, rel_path: path }, title); + }, [openTab]); const handleSelectLocation = setSelectedLocation; - const handleSelectTab = selectTab; - const handleCloseTab = closeTab; - const handleReorderTabs = reorderTabs; + + const handleSelectTab = useCallback((tabId: string) => { + void runCmd(sessionSelectTab(tabId, applySession, (error) => { + logger.error(f("Failed to select session tab", { tabId, error })); + })); + }, [applySession]); + + const handleCloseTab = useCallback((tabId: string) => { + void runCmd(sessionCloseTab(tabId, applySession, (error) => { + logger.error(f("Failed to close session tab", { tabId, error })); + })); + }, [applySession]); + + const handleReorderTabs = useCallback((nextTabs: Tab[]) => { + void runCmd(sessionReorderTabs(nextTabs.map((tab) => tab.id), applySession, (error) => { + logger.error(f("Failed to reorder session tabs", { error })); + })); + }, [applySession]); + + const markActiveTabModified = useCallback((isModified: boolean) => { + if (!activeTabId) { + return; + } + + void runCmd(sessionMarkTabModified(activeTabId, isModified, applySession, (error) => { + logger.error(f("Failed to mark session tab modified", { activeTabId, isModified, error })); + })); + }, [activeTabId, applySession]); const handleCreateDraftTab = useCallback((docRef: DocRef, title: string) => { - openDocumentTab(docRef, title); - }, [openDocumentTab]); + openTab(docRef, title); + }, [openTab]); const handleCreateNewDocument = useCallback((locationId?: number) => { const workspaceState = useWorkspaceStore.getState(); - const tabsState = useTabsStore.getState(); const requestedLocationId = toLocationId(locationId); const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id; @@ -119,11 +178,11 @@ export function useWorkspaceController() { return null; } - const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabsState.tabs); + const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabs); const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; - openDocumentTab(docRef, getDraftTitle(relPath)); + openTab(docRef, getDraftTitle(relPath)); return docRef; - }, [openDocumentTab]); + }, [openTab, tabs]); const handleRefreshSidebar = useCallback((locationId?: number, options: RefreshSidebarOptions = {}) => { const source = options.source ?? "manual"; @@ -182,25 +241,22 @@ export function useWorkspaceController() { return new Promise((resolve) => { runCmd(docRename(locationId, relPath, newName, (newMeta) => { const workspaceState = useWorkspaceStore.getState(); - const tabsState = useTabsStore.getState(); - - const affectedTab = tabsState.tabs.find((tab) => - tab.docRef.location_id === locationId && tab.docRef.rel_path === relPath - ); - - if (affectedTab) { - const newDocRef: DocRef = { location_id: locationId, rel_path: newMeta.rel_path }; - const updatedTabs = tabsState.tabs.map((tab) => - tab.id === affectedTab.id ? { ...tab, docRef: newDocRef, title: newMeta.title } : tab - ); - tabsState.reorderTabs(updatedTabs); - } - const updatedDocuments = workspaceState.documents.map((doc) => doc.location_id === locationId && doc.rel_path === relPath ? newMeta : doc ); workspaceState.setDocuments(updatedDocuments); + void runCmd( + sessionUpdateTabDoc( + locationId, + relPath, + { location_id: locationId, rel_path: newMeta.rel_path }, + newMeta.title, + applySession, + () => {}, + ), + ); + logger.info(f("Document renamed", { locationId, oldPath: relPath, newPath: newMeta.rel_path })); resolve(true); }, (error: AppError) => { @@ -208,32 +264,29 @@ export function useWorkspaceController() { resolve(false); })); }); - }, []); + }, [applySession]); const handleMoveDocument = useCallback( (locationId: number, relPath: string, newRelPath: string): Promise => { return new Promise((resolve) => { runCmd(docMove(locationId, relPath, newRelPath, (newMeta) => { const workspaceState = useWorkspaceStore.getState(); - const tabsState = useTabsStore.getState(); - - const affectedTab = tabsState.tabs.find((tab) => - tab.docRef.location_id === locationId && tab.docRef.rel_path === relPath - ); - - if (affectedTab) { - const newDocRef: DocRef = { location_id: locationId, rel_path: newMeta.rel_path }; - const updatedTabs = tabsState.tabs.map((tab) => - tab.id === affectedTab.id ? { ...tab, docRef: newDocRef, title: newMeta.title } : tab - ); - tabsState.reorderTabs(updatedTabs); - } - const updatedDocuments = workspaceState.documents.map((doc) => doc.location_id === locationId && doc.rel_path === relPath ? newMeta : doc ); workspaceState.setDocuments(updatedDocuments); + void runCmd( + sessionUpdateTabDoc( + locationId, + relPath, + { location_id: locationId, rel_path: newMeta.rel_path }, + newMeta.title, + applySession, + () => {}, + ), + ); + logger.info(f("Document moved", { locationId, oldPath: relPath, newPath: newMeta.rel_path })); resolve(true); }, (error: AppError) => { @@ -242,7 +295,7 @@ export function useWorkspaceController() { })); }); }, - [], + [applySession], ); const handleDeleteDocument = useCallback((locationId: number, relPath: string): Promise => { @@ -254,21 +307,13 @@ export function useWorkspaceController() { } const workspaceState = useWorkspaceStore.getState(); - const tabsState = useTabsStore.getState(); - - const affectedTab = tabsState.tabs.find((tab) => - tab.docRef.location_id === locationId && tab.docRef.rel_path === relPath - ); - - if (affectedTab) { - tabsState.closeTab(affectedTab.id); - } - const updatedDocuments = workspaceState.documents.filter((doc) => !(doc.location_id === locationId && doc.rel_path === relPath) ); workspaceState.setDocuments(updatedDocuments); + void runCmd(sessionDropDoc(locationId, relPath, applySession, () => {})); + logger.info(f("Document deleted", { locationId, relPath })); resolve(true); }, (error: AppError) => { @@ -276,7 +321,7 @@ export function useWorkspaceController() { resolve(false); })); }); - }, []); + }, [applySession]); const handleCreateDirectory = useCallback( (locationId: number, parentRelPath: string, newDirectoryName: string): Promise => { @@ -311,6 +356,7 @@ export function useWorkspaceController() { locationDocuments, sidebarFilter, isSidebarLoading: isLoadingLocations || isLoadingDocuments, + isSessionHydrated, refreshingLocationId, sidebarRefreshReason, tabs, @@ -342,6 +388,7 @@ export function useWorkspaceController() { sidebarFilter, isLoadingLocations, isLoadingDocuments, + isSessionHydrated, refreshingLocationId, sidebarRefreshReason, tabs, diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index c43611b..def4e44 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -56,6 +56,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { documents, selectedLocationId, isSidebarLoading, + isSessionHydrated, tabs, activeTab, markActiveTabModified, @@ -142,6 +143,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { useDocumentSessionEffects({ isSidebarLoading, + isSessionHydrated, locations, selectedLocationId, tabs, diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 7d6afca..ea82ae2 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -5,13 +5,13 @@ import type { CaptureSubmitResult, DocContent, DocMeta, - DocRef, GlobalCaptureSettings, LocationDescriptor, LocationId, MarkdownProfile, RenderResult, SearchHit, + SessionState, } from "$types"; import { info } from "@tauri-apps/plugin-log"; import { invokeCmd } from "./invoke"; @@ -42,7 +42,14 @@ import type { SaveResult, SearchFiltersPayload, SearchParams, - SessionLastDocSetParams, + SessionDropDocParams, + SessionMarkTabModifiedParams, + SessionOpenTabParams, + SessionParams, + SessionPruneLocationsParams, + SessionReorderTabsParams, + SessionTabIdParams, + SessionUpdateTabDocParams, StyleCheckSetParams, UiLayoutSetParams, UiLayoutSettings, @@ -234,12 +241,46 @@ export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams("ui_layout_set", { settings }, onOk, onErr); } -export function sessionLastDocGet(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("session_last_doc_get", {}, onOk, onErr); +export function sessionGet(...[onOk, onErr]: SessionParams): Cmd { + return invokeCmd("session_get", {}, onOk, onErr); } -export function sessionLastDocSet(...[docRef, onOk, onErr]: SessionLastDocSetParams): Cmd { - return invokeCmd("session_last_doc_set", { docRef }, onOk, onErr); +export function sessionOpenTab(...[docRef, title, onOk, onErr]: SessionOpenTabParams): Cmd { + return invokeCmd("session_open_tab", { docRef, title }, onOk, onErr); +} + +export function sessionSelectTab(...[tabId, onOk, onErr]: SessionTabIdParams): Cmd { + return invokeCmd("session_select_tab", { tabId }, onOk, onErr); +} + +export function sessionCloseTab(...[tabId, onOk, onErr]: SessionTabIdParams): Cmd { + return invokeCmd("session_close_tab", { tabId }, onOk, onErr); +} + +export function sessionReorderTabs(...[tabIds, onOk, onErr]: SessionReorderTabsParams): Cmd { + return invokeCmd("session_reorder_tabs", { tabIds }, onOk, onErr); +} + +export function sessionMarkTabModified( + ...[tabId, isModified, onOk, onErr]: SessionMarkTabModifiedParams +): Cmd { + return invokeCmd("session_mark_tab_modified", { tabId, isModified }, onOk, onErr); +} + +export function sessionUpdateTabDoc( + ...[locationId, oldRelPath, newDocRef, title, onOk, onErr]: SessionUpdateTabDocParams +): Cmd { + return invokeCmd("session_update_tab_doc", { locationId, oldRelPath, newDocRef, title }, onOk, onErr); +} + +export function sessionDropDoc(...[locationId, relPath, onOk, onErr]: SessionDropDocParams): Cmd { + return invokeCmd("session_drop_doc", { locationId, relPath }, onOk, onErr); +} + +export function sessionPruneLocations( + ...[validLocationIds, onOk, onErr]: SessionPruneLocationsParams +): Cmd { + return invokeCmd("session_prune_locations", { validLocationIds }, onOk, onErr); } export function styleCheckGet(...[onOk, onErr]: LocParams): Cmd { diff --git a/src/ports/invoke.ts b/src/ports/invoke.ts index c24b9c6..a786771 100644 --- a/src/ports/invoke.ts +++ b/src/ports/invoke.ts @@ -8,6 +8,7 @@ import type { ErrorCode, GlobalCaptureSettings, SearchHit, + SessionState, } from "$types"; import { f } from "$utils/serialize"; import type { InvokeArgs } from "@tauri-apps/api/core"; @@ -237,6 +238,36 @@ function normalizeCaptureSubmitResult(value: unknown): CaptureSubmitResult { }; } +function normalizeSessionState(value: unknown): SessionState { + if (!isRecord(value) || !Array.isArray(value.tabs)) { + return { tabs: [], activeTabId: null }; + } + + const tabs = value.tabs.map((tab) => { + if (!isRecord(tab)) { + return null; + } + if (typeof tab.id !== "string" || !tab.id.trim()) { + return null; + } + + const docRef = normalizeDocRef(tab.doc_ref); + if (!docRef) { + return null; + } + + return { + id: tab.id, + docRef, + title: typeof tab.title === "string" ? tab.title : docRef.rel_path.split("/").pop() || "Untitled", + isModified: typeof tab.is_modified === "boolean" ? tab.is_modified : false, + }; + }).filter((tab): tab is SessionState["tabs"][number] => tab !== null); + + const activeTabId = typeof value.active_tab_id === "string" ? value.active_tab_id : null; + return { tabs, activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id ?? null }; +} + function normalizeCommandValue(command: string, value: unknown): unknown { switch (command) { case "doc_list": { @@ -269,8 +300,16 @@ function normalizeCommandValue(command: string, value: unknown): unknown { case "global_capture_submit": { return normalizeCaptureSubmitResult(value); } - case "session_last_doc_get": { - return normalizeDocRef(value); + case "session_get": + case "session_open_tab": + case "session_select_tab": + case "session_close_tab": + case "session_reorder_tabs": + case "session_mark_tab_modified": + case "session_update_tab_doc": + case "session_drop_doc": + case "session_prune_locations": { + return normalizeSessionState(value); } default: { return value; diff --git a/src/ports/types.ts b/src/ports/types.ts index 00d51ad..d5c3ba7 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -14,6 +14,7 @@ import type { RenderResult, SaveStatus, SearchHit, + SessionState, StyleCheckPattern, StyleMarkerStyle, } from "$types"; @@ -131,10 +132,41 @@ export type UiLayoutSetParams = Parameters< (settings: UiLayoutSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void >; -export type SessionLastDocSetParams = Parameters< - (docRef: DocRef | null, onOk: SuccessCallback, onErr: ErrorCallback) => void +export type SessionOpenTabParams = Parameters< + (docRef: DocRef, title: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; +export type SessionTabIdParams = Parameters<(tabId: string, onOk: SuccessCallback, onErr: ErrorCallback) => void>; + +export type SessionReorderTabsParams = Parameters< + (tabIds: string[], onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type SessionMarkTabModifiedParams = Parameters< + (tabId: string, isModified: boolean, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type SessionUpdateTabDocParams = Parameters< + ( + locationId: number, + oldRelPath: string, + newDocRef: DocRef, + title: string, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void +>; + +export type SessionDropDocParams = Parameters< + (locationId: number, relPath: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type SessionPruneLocationsParams = Parameters< + (validLocationIds: number[], onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type SessionParams = LocParams; + export type StyleCheckSetParams = Parameters< (settings: PersistedStyleCheckSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void >; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index ea137f4..710357b 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -162,19 +162,17 @@ export const useWorkspaceDocumentsActions = () => ); export const useTabsState = () => - useTabsStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }))); - -export const useTabsActions = () => useTabsStore( useShallow((state) => ({ - openDocumentTab: state.openDocumentTab, - selectTab: state.selectTab, - closeTab: state.closeTab, - reorderTabs: state.reorderTabs, - markActiveTabModified: state.markActiveTabModified, + tabs: state.tabs, + activeTabId: state.activeTabId, + isSessionHydrated: state.isSessionHydrated, })), ); +export const useTabsActions = () => + useTabsStore(useShallow((state) => ({ applySessionState: state.applySessionState }))); + export const usePdfExportState = () => usePdfExportStore( useShallow((state) => ({ isExportingPdf: state.isExportingPdf, pdfExportError: state.pdfExportError })), diff --git a/src/state/stores/tabs.ts b/src/state/stores/tabs.ts index c17872f..958eb34 100644 --- a/src/state/stores/tabs.ts +++ b/src/state/stores/tabs.ts @@ -1,134 +1,25 @@ import type { TabsActions, TabsState } from "$state/types"; -import type { Tab } from "$types"; import { create } from "zustand"; import { useWorkspaceStore } from "./workspace"; export type TabsStore = TabsState & TabsActions; -function generateTabId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } +export const getInitialTabsState = (): TabsState => ({ tabs: [], activeTabId: null, isSessionHydrated: false }); - return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export const getInitialTabsState = (): TabsState => ({ tabs: [], activeTabId: null }); - -export const useTabsStore = create()((set, get) => ({ +export const useTabsStore = create()((set) => ({ ...getInitialTabsState(), - openDocumentTab: (docRef, title) => { - const existingTab = get().tabs.find((tab) => - tab.docRef.location_id === docRef.location_id && tab.docRef.rel_path === docRef.rel_path - ); - - if (existingTab) { - useWorkspaceStore.setState({ selectedLocationId: docRef.location_id, selectedDocPath: docRef.rel_path }); - set({ activeTabId: existingTab.id }); - return { tabId: existingTab.id, didCreateTab: false }; - } + applySessionState: (session) => { + set({ tabs: session.tabs, activeTabId: session.activeTabId, isSessionHydrated: true }); - const newTab: Tab = { id: generateTabId(), docRef, title, isModified: false }; - set((state) => ({ tabs: [...state.tabs, newTab], activeTabId: newTab.id })); - useWorkspaceStore.setState({ selectedLocationId: docRef.location_id, selectedDocPath: docRef.rel_path }); - return { tabId: newTab.id, didCreateTab: true }; - }, - - selectTab: (tabId) => { - const tab = get().tabs.find((item) => item.id === tabId); - if (!tab) { - return null; - } - - set({ activeTabId: tab.id }); - useWorkspaceStore.setState({ selectedLocationId: tab.docRef.location_id, selectedDocPath: tab.docRef.rel_path }); - return tab.docRef; - }, - - closeTab: (tabId) => { - const state = get(); - const tabIndex = state.tabs.findIndex((item) => item.id === tabId); - - if (tabIndex === -1) { - return null; - } - - const tabs = state.tabs.filter((item) => item.id !== tabId); - - if (state.activeTabId !== tabId) { - set({ tabs }); - return null; - } - - if (tabs.length === 0) { - set({ tabs, activeTabId: null }); - useWorkspaceStore.setState({ selectedDocPath: undefined }); - return null; - } - - const nextIndex = Math.min(tabIndex, tabs.length - 1); - const nextActiveTab = tabs[nextIndex]; - set({ tabs, activeTabId: nextActiveTab.id }); + const activeTab = session.tabs.find((tab) => tab.id === session.activeTabId) ?? null; useWorkspaceStore.setState({ - selectedLocationId: nextActiveTab.docRef.location_id, - selectedDocPath: nextActiveTab.docRef.rel_path, - }); - - return nextActiveTab.docRef; - }, - - reorderTabs: (tabs) => set({ tabs }), - - markActiveTabModified: (isModified) => { - set((state) => { - if (!state.activeTabId) { - return state; - } - - const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); - if (!activeTab || activeTab.isModified === isModified) { - return state; - } - - return { tabs: state.tabs.map((tab) => (tab.id === state.activeTabId ? { ...tab, isModified } : tab)) }; + selectedLocationId: activeTab?.docRef.location_id, + selectedDocPath: activeTab?.docRef.rel_path, }); }, })); -let hasInitializedWorkspaceSync = false; - -function initializeWorkspaceSync(): void { - if (hasInitializedWorkspaceSync) { - return; - } - hasInitializedWorkspaceSync = true; - - useWorkspaceStore.subscribe((workspaceState, previousWorkspaceState) => { - if (workspaceState.locations === previousWorkspaceState.locations) { - return; - } - - const validLocationIds = new Set(workspaceState.locations.map((location) => location.id)); - const tabsState = useTabsStore.getState(); - const nextTabs = tabsState.tabs.filter((tab) => validLocationIds.has(tab.docRef.location_id)); - - if (nextTabs.length === tabsState.tabs.length) { - return; - } - - const activeTabStillExists = tabsState.activeTabId !== null - && nextTabs.some((tab) => tab.id === tabsState.activeTabId); - useTabsStore.setState({ tabs: nextTabs, activeTabId: activeTabStillExists ? tabsState.activeTabId : null }); - - if (!activeTabStillExists) { - useWorkspaceStore.setState({ selectedDocPath: undefined }); - } - }); -} - -initializeWorkspaceSync(); - export function resetTabsStore(): void { useTabsStore.setState(getInitialTabsState()); } diff --git a/src/state/types.ts b/src/state/types.ts index f0df5c4..11c9545 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -2,7 +2,6 @@ import type { MarginSide, Orientation, PageSize, PdfExportOptions } from "$pdf/t import type { AppTheme, DocMeta, - DocRef, EditorFontFamily, FocusDimmingMode, FocusModeSettings, @@ -10,12 +9,12 @@ import type { LocationDescriptor, PatternCategory, SearchHit, + SessionState, StyleCheckPattern, StyleCheckSettings, Tab, } from "$types"; -export type OpenDocumentTabResult = { tabId: string; didCreateTab: boolean }; export type SearchFilters = { locations?: number[]; fileTypes?: string[]; dateRange?: { from?: Date; to?: Date } }; export type LayoutChromeState = { @@ -138,15 +137,9 @@ export type WorkspaceState = WorkspaceLocationsState & WorkspaceDocumentsState; export type WorkspaceActions = WorkspaceLocationsActions & WorkspaceDocumentsActions; -export type TabsState = { tabs: Tab[]; activeTabId: string | null }; +export type TabsState = { tabs: Tab[]; activeTabId: string | null; isSessionHydrated: boolean }; -export type TabsActions = { - openDocumentTab: (docRef: DocRef, title: string) => OpenDocumentTabResult; - selectTab: (tabId: string) => DocRef | null; - closeTab: (tabId: string) => DocRef | null; - reorderTabs: (tabs: Tab[]) => void; - markActiveTabModified: (isModified: boolean) => void; -}; +export type TabsActions = { applySessionState: (session: SessionState) => void }; export type PdfExportState = { isExportingPdf: boolean; pdfExportError: string | null }; diff --git a/src/types.ts b/src/types.ts index 06cd212..19f78fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ export type DocRef = { location_id: LocationId; rel_path: string }; export type Tab = { id: string; docRef: DocRef; title: string; isModified: boolean; isPinned?: boolean }; +export type SessionState = { tabs: Tab[]; activeTabId: string | null }; + export type DocMeta = { location_id: LocationId; rel_path: string; -- 2.51.2