diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a44e2..e8b2030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,17 @@ ## v0.3.0 -### Features +### Added - AT Protocol integration (login with your [internet handle](https://internethandle.org/)) - Import strings (snippets) from [Tangled](https://tangled.org/) - Import [standard.site](https://standard.site/) posts ([Leaflet](https://leaflet.pub)) + +### Changed + - UI/Design overhaul - reduced visual clutter +- Persist Sidebar/File Browser state between reloads +- New app icon ## v0.2.0 — 2026-03-20 diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index c8205fb..77d19d8 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use rusqlite::{Connection, OptionalExtension, params, params_from_iter, types::Value}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs::File; @@ -17,10 +18,13 @@ mod file_utils; mod settings; mod text_utils; -pub use settings::{CaptureDocRef, CaptureMode, FocusDimmingMode, MarkdownPreviewStyle, SessionState, SessionTab}; +pub use settings::{ + CaptureDocRef, CaptureMode, FocusDimmingMode, MarkdownPreviewStyle, SessionState, SessionTab, SidebarTreeState, +}; pub use settings::{GlobalCaptureSettings, StyleCheckSettings, UiLayoutSettings}; const UI_LAYOUT_SETTINGS_KEY: &str = "ui_layout"; +const SIDEBAR_TREE_STATE_KEY: &str = "sidebar_tree"; 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"; @@ -193,10 +197,78 @@ impl Store { ) .map_err(|e| AppError::io(format!("Failed to create app_settings table: {}", e)))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| AppError::io(format!("Failed to create kv table: {}", e)))?; + log::debug!("Database schema initialized"); Ok(()) } + fn kv_get_json(&self, key: &str) -> Result, AppError> + where + T: DeserializeOwned, + { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let maybe_value = conn + .query_row("SELECT value FROM kv WHERE key = ?1", params![key], |row| { + row.get::<_, String>(0) + }) + .optional() + .map_err(|e| AppError::io(format!("Failed to query kv entry for {}: {}", key, e)))?; + + match maybe_value { + Some(value) => serde_json::from_str::(&value) + .map(Some) + .map_err(|e| AppError::new(ErrorCode::Parse, format!("Failed to parse kv entry {}: {}", key, e))), + None => Ok(None), + } + } + + fn kv_set_json(&self, key: &str, value: &T) -> Result<(), AppError> + where + T: Serialize, + { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let payload = serde_json::to_string(value) + .map_err(|e| AppError::new(ErrorCode::Parse, format!("Failed to serialize kv entry {}: {}", key, e)))?; + let updated_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO kv (key, value, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at", + params![key, payload, updated_at], + ) + .map_err(|e| AppError::io(format!("Failed to persist kv entry {}: {}", key, e)))?; + + Ok(()) + } + + pub fn sidebar_tree_get(&self) -> Result { + Ok(self.kv_get_json(SIDEBAR_TREE_STATE_KEY)?.unwrap_or_default()) + } + + pub fn sidebar_tree_set(&self, state: &SidebarTreeState) -> Result<(), AppError> { + self.kv_set_json(SIDEBAR_TREE_STATE_KEY, state) + } + pub fn ui_layout_get(&self) -> Result { let conn = self .conn @@ -2738,6 +2810,55 @@ mod tests { assert_eq!(loaded.markdown_preview_style, MarkdownPreviewStyle::Github); } + #[test] + fn test_sidebar_tree_state_defaults() { + let (store, _temp) = create_test_store(); + let state = store.sidebar_tree_get().unwrap(); + + assert_eq!(state, SidebarTreeState::default()); + } + + #[test] + fn test_sidebar_tree_state_round_trip() { + let (store, _temp) = create_test_store(); + let state = SidebarTreeState { + expanded_location_ids: vec![3, 7], + expanded_directories_by_location: std::collections::BTreeMap::from([ + (3, vec!["Drafts".to_string(), "Drafts/2026".to_string()]), + (7, vec!["Archive".to_string()]), + ]), + }; + + store.sidebar_tree_set(&state).unwrap(); + let loaded = store.sidebar_tree_get().unwrap(); + + assert_eq!(loaded, state); + } + + #[test] + fn test_sidebar_tree_state_backfills_directory_defaults() { + let (store, _temp) = create_test_store(); + let conn = store + .conn + .lock() + .expect("expected to lock database connection for test"); + + conn.execute( + "INSERT INTO kv (key, value, updated_at) VALUES (?1, ?2, ?3)", + params![ + SIDEBAR_TREE_STATE_KEY, + "{\"expanded_location_ids\":[5]}", + Utc::now().to_rfc3339(), + ], + ) + .unwrap(); + drop(conn); + + let loaded = store.sidebar_tree_get().unwrap(); + assert_eq!(loaded.expanded_location_ids, vec![5]); + assert!(loaded.expanded_directories_by_location.is_empty()); + } + #[test] fn test_style_check_settings_defaults() { let (store, _temp) = create_test_store(); diff --git a/crates/store/src/settings.rs b/crates/store/src/settings.rs index 86747b7..d0d2cec 100644 --- a/crates/store/src/settings.rs +++ b/crates/store/src/settings.rs @@ -1,5 +1,6 @@ use super::StyleCheckPattern; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; fn default_true() -> bool { true @@ -149,6 +150,14 @@ impl Default for UiLayoutSettings { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SidebarTreeState { + #[serde(default)] + pub expanded_location_ids: Vec, + #[serde(default)] + pub expanded_directories_by_location: BTreeMap>, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum CaptureMode { #[default] diff --git a/docs/architecture.md b/docs/architecture.md index 1201f8a..fd2a03b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ --- title: "Architecture" -last_updated: 2026-03-13 +last_updated: 2026-03-21 --- ## System Overview @@ -38,6 +38,7 @@ Frontend orchestration is split across focused hooks rather than a single app co - locations, documents, tabs, sidebar refresh, file operations - `src/hooks/app/useSettingsSync.ts` - backend settings hydration and persistence + - sidebar tree hydration/persistence through sqlite-backed kv state ### State diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 3698fc5..f9d9317 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -44,7 +44,7 @@ Core flow via `useWorkspaceSync`, `useWorkspaceController`, and `useDocumentSess 1. Load locations. 2. Start filesystem watchers for active locations. 3. Select an initial location if available. -4. Load documents for the selected location. +4. Load documents/directories for the selected location and keep per-location sidebar caches warm for expanded trees. 5. Load Rust-backed session state (`session_get`) and prune tabs for removed locations. 6. Open the active document or create a draft when startup has no restorable tab. 7. Track and refresh sidebar contents for saves, manual refreshes, and external filesystem events. @@ -70,6 +70,7 @@ Core flow via `useWorkspaceSync`, `useWorkspaceController`, and `useDocumentSess Persisted backend settings include: - UI layout settings +- sidebar tree expansion state - style-check settings - global capture settings diff --git a/docs/persistence.md b/docs/persistence.md index 928e0b1..23be0dd 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -1,6 +1,6 @@ --- title: "Persistence" -last_updated: 2026-02-26 +last_updated: 2026-03-21 --- ## Source of Truth @@ -25,6 +25,7 @@ Key persisted domains: - indexed document metadata - search index data - app settings (UI layout, style check, global capture) +- kv state (sidebar expanded locations and expanded directories) - session restore metadata (last opened document) ## Filesystem Access Scope diff --git a/docs/state.md b/docs/state.md index 86d40f9..d2891b3 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ --- title: "State Management" -last_updated: 2026-03-13 +last_updated: 2026-03-21 --- ## Frontend State Model @@ -18,8 +18,9 @@ Current runtime stores: - writer tools - `workspace.ts` - locations - - documents - - directories + - selected location documents/directories + - per-location sidebar document/directory caches + - persisted expanded location/directory state - sidebar drag/drop state - `tabs.ts` - open tabs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 745b8da..17d412b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,7 +13,7 @@ use writer_core::{ AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, LocationDescriptor, LocationId, SaveResult, SearchFilters, SearchHit, StyleCategorySettings, StyleMatch, StylePatternInput, StyleScanInput, }; -use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; +use writer_store::{SidebarTreeState, Store, StyleCheckSettings, UiLayoutSettings}; mod atproto; mod md; @@ -199,6 +199,32 @@ pub fn ui_layout_set(state: State<'_, AppState>, settings: UiLayoutSettings) -> } } +#[tauri::command] +pub fn sidebar_tree_get(state: State<'_, AppState>) -> CommandResponse { + log::debug!("Loading persisted sidebar tree state"); + + match state.store.sidebar_tree_get() { + Ok(sidebar_tree_state) => Ok(CommandResult::ok(sidebar_tree_state)), + Err(e) => { + log::error!("Failed to load sidebar tree state: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +#[tauri::command] +pub fn sidebar_tree_set(state: State<'_, AppState>, state_value: SidebarTreeState) -> CommandResponse { + log::debug!("Persisting sidebar tree state"); + + match state.store.sidebar_tree_set(&state_value) { + Ok(()) => Ok(CommandResult::ok(true)), + Err(e) => { + log::error!("Failed to persist sidebar tree state: {}", e); + Ok(CommandResult::err(e)) + } + } +} + #[tauri::command] pub fn session_last_doc_get(state: State<'_, AppState>) -> CommandResponse> { log::debug!("Loading last opened document session state"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f1e6bf..f01e67c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -125,6 +125,8 @@ pub fn run() { cmd::markdown_render_for_docx, cmd::ui_layout_get, cmd::ui_layout_set, + cmd::sidebar_tree_get, + cmd::sidebar_tree_set, cmd::session_get, cmd::session_open_tab, cmd::session_select_tab, diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index 73f2ab7..7fa8c13 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -25,36 +25,57 @@ vi.mock("$dnd", async () => { }; }); -const createSidebarState = (overrides: Partial> = {}) => ({ - locations: [{ id: 1, name: "Notes", root_path: "/tmp/notes", added_at: "2026-01-01T00:00:00Z" }, { - id: 2, - name: "Archive", - root_path: "/tmp/archive", - added_at: "2026-01-01T00:00:00Z", - }], - selectedLocationId: 1, - selectedDocPath: undefined, - documents: [], - directories: [], - isLoading: false, - refreshingLocationId: undefined, - sidebarRefreshReason: null, - externalDropTargetId: undefined, - externalDropFolderPath: undefined, - activeDropTarget: null, - folderSortOrderByLocation: {}, - filterText: "", - setFilterText: vi.fn(), - setDocuments: vi.fn(), - setDirectories: vi.fn(), - selectLocation: vi.fn(), - toggleSidebarCollapsed: vi.fn(), - filenameVisibility: false, - setExternalDropTarget: vi.fn(), - setActiveDropTarget: vi.fn(), - reorderFolderSortOrder: vi.fn(), - ...overrides, -}); +const createSidebarState = (overrides: Partial> = {}) => { + const documents = overrides.documents ?? []; + const directories = overrides.directories ?? []; + const selectedLocationId = overrides.selectedLocationId ?? 1; + const documentsByLocation = overrides.documentsByLocation + ?? (selectedLocationId ? { [selectedLocationId]: documents } : {}); + const directoriesByLocation = overrides.directoriesByLocation + ?? (selectedLocationId ? { [selectedLocationId]: directories } : {}); + + return { + locations: [{ id: 1, name: "Notes", root_path: "/tmp/notes", added_at: "2026-01-01T00:00:00Z" }, { + id: 2, + name: "Archive", + root_path: "/tmp/archive", + added_at: "2026-01-01T00:00:00Z", + }], + selectedLocationId, + selectedDocPath: undefined, + documents, + directories, + documentsByLocation, + directoriesByLocation, + expandedLocationIds: overrides.expandedLocationIds ?? [1], + expandedDirectoriesByLocation: overrides.expandedDirectoriesByLocation ?? {}, + isLoading: false, + refreshingLocationId: undefined, + sidebarRefreshReason: null, + externalDropTargetId: undefined, + externalDropFolderPath: undefined, + activeDropTarget: null, + folderSortOrderByLocation: {}, + filterText: "", + setFilterText: vi.fn(), + setDocuments: vi.fn(), + setDirectories: vi.fn(), + setDocumentsForLocation: vi.fn(), + setDirectoriesForLocation: vi.fn(), + setSidebarTreeState: vi.fn(), + selectLocation: vi.fn(), + toggleExpandedLocation: vi.fn(), + toggleExpandedDirectory: vi.fn(), + expandDirectories: vi.fn(), + collapseDirectories: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + filenameVisibility: false, + setExternalDropTarget: vi.fn(), + setActiveDropTarget: vi.fn(), + reorderFolderSortOrder: vi.fn(), + ...overrides, + }; +}; const createSidebarActionsState = ( overrides: Partial> = {}, @@ -164,18 +185,40 @@ describe("Sidebar", () => { updated_at: "2026-02-27T10:15:00Z", word_count: 12, }], + expandedDirectoriesByLocation: { 1: ["inbox", "inbox/2026"] }, }), ); render(); expect(screen.getByText("inbox")).toBeInTheDocument(); - expect(screen.queryByText("Quick capture note")).not.toBeInTheDocument(); + expect(screen.getByText("Quick capture note")).toBeInTheDocument(); + }); - fireEvent.click(screen.getByText("inbox")); - fireEvent.click(screen.getByText("2026")); + it("renders the tree for an expanded non-selected location", () => { + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + selectedLocationId: 1, + expandedLocationIds: [1, 2], + documentsByLocation: { + 1: [], + 2: [{ + location_id: 2, + rel_path: "archive/note.md", + title: "Archived Note", + updated_at: "2026-02-27T10:15:00Z", + word_count: 12, + }], + }, + directoriesByLocation: { 1: [], 2: ["archive"] }, + expandedDirectoriesByLocation: { 2: ["archive"] }, + }), + ); - expect(screen.getByText("Quick capture note")).toBeInTheDocument(); + render(); + + expect(screen.getByText("archive")).toBeInTheDocument(); + expect(screen.getByText("Archived Note")).toBeInTheDocument(); }); it("renders empty directories from backend directory listing", () => { diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index 91f033b..b6c42a3 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -75,31 +75,50 @@ type WorkspacePanelPropOverrides = { welcome?: Partial>; }; -const createSidebarState = (overrides: Partial = {}): SidebarStateReturn => ({ - locations: [], - selectedLocationId: undefined, - selectedDocPath: undefined, - documents: [], - directories: [], - isLoading: false, - refreshingLocationId: undefined, - sidebarRefreshReason: null, - externalDropTargetId: undefined, - externalDropFolderPath: undefined, - activeDropTarget: null, - folderSortOrderByLocation: {}, - filterText: "", - setFilterText: vi.fn(), - setDocuments: vi.fn(), - setDirectories: vi.fn(), - selectLocation: vi.fn(), - toggleSidebarCollapsed: vi.fn(), - filenameVisibility: false, - setExternalDropTarget: vi.fn(), - setActiveDropTarget: vi.fn(), - reorderFolderSortOrder: vi.fn(), - ...overrides, -}); +const createSidebarState = (overrides: Partial = {}): SidebarStateReturn => { + const selectedLocationId = overrides.selectedLocationId; + const documents = overrides.documents ?? []; + const directories = overrides.directories ?? []; + + return { + locations: [], + selectedLocationId, + selectedDocPath: undefined, + documents, + directories, + documentsByLocation: overrides.documentsByLocation + ?? (selectedLocationId ? { [selectedLocationId]: documents } : {}), + directoriesByLocation: overrides.directoriesByLocation + ?? (selectedLocationId ? { [selectedLocationId]: directories } : {}), + expandedLocationIds: overrides.expandedLocationIds ?? [], + expandedDirectoriesByLocation: overrides.expandedDirectoriesByLocation ?? {}, + isLoading: false, + refreshingLocationId: undefined, + sidebarRefreshReason: null, + externalDropTargetId: undefined, + externalDropFolderPath: undefined, + activeDropTarget: null, + folderSortOrderByLocation: {}, + filterText: "", + setFilterText: vi.fn(), + setDocuments: vi.fn(), + setDirectories: vi.fn(), + setDocumentsForLocation: vi.fn(), + setDirectoriesForLocation: vi.fn(), + setSidebarTreeState: vi.fn(), + selectLocation: vi.fn(), + toggleExpandedLocation: vi.fn(), + toggleExpandedDirectory: vi.fn(), + expandDirectories: vi.fn(), + collapseDirectories: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + filenameVisibility: false, + setExternalDropTarget: vi.fn(), + setActiveDropTarget: vi.fn(), + reorderFolderSortOrder: vi.fn(), + ...overrides, + }; +}; const createToolbarState = (overrides: Partial = {}): ToolbarStateReturn => ({ isSplitView: false, @@ -431,7 +450,7 @@ describe("WorkspacePanel", () => { expect(screen.getByTestId("workspace-welcome-screen")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /create new/i })); fireEvent.click(screen.getByRole("button", { name: /open existing/i })); - fireEvent.click(screen.getByRole("button", { name: /import tangled/i })); + fireEvent.click(screen.getByRole("button", { name: /import from tangled/i })); fireEvent.click(screen.getByRole("button", { name: /import standard\.site/i })); fireEvent.click(screen.getByRole("button", { name: /add another location/i })); diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index f70c192..df28fd6 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -42,6 +42,8 @@ import { sessionReorderTabs, sessionSelectTab, sessionUpdateTabDoc, + sidebarTreeGet, + sidebarTreeSet, startWatch, stopWatch, stringGet, @@ -1026,6 +1028,41 @@ describe("ui layout Commands", () => { }); }); +describe("sidebar tree Commands", () => { + describe(sidebarTreeGet, () => { + it("should create command with empty payload", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + const cmd = sidebarTreeGet(onOk, onErr) as InvokeCmd; + + expect(cmd.type).toBe("Invoke"); + expect(cmd.command).toBe("sidebar_tree_get"); + expect(cmd.payload).toStrictEqual({}); + }); + }); + + describe(sidebarTreeSet, () => { + it("should create command with sidebar tree payload", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + const cmd = sidebarTreeSet( + { expanded_location_ids: [1, 2], expanded_directories_by_location: { 1: ["Drafts"], 2: ["Archive/2026"] } }, + onOk, + onErr, + ) as InvokeCmd; + + expect(cmd.type).toBe("Invoke"); + expect(cmd.command).toBe("sidebar_tree_set"); + expect(cmd.payload).toStrictEqual({ + stateValue: { + expanded_location_ids: [1, 2], + expanded_directories_by_location: { 1: ["Drafts"], 2: ["Archive/2026"] }, + }, + }); + }); + }); +}); + describe("session Commands", () => { describe(sessionGet, () => { it("should create command with empty payload", () => { diff --git a/src/__tests__/stores/app.test.ts b/src/__tests__/stores/app.test.ts index 797e152..b8c3b84 100644 --- a/src/__tests__/stores/app.test.ts +++ b/src/__tests__/stores/app.test.ts @@ -65,6 +65,36 @@ describe("appStore", () => { expect(useAppStore.getState().selectedDocPath).toBe("notes/new.md"); }); + it("hydrates selected location tree from the per-location cache", () => { + 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.setDocumentsForLocation(2, [{ + location_id: 2, + rel_path: "archive.md", + title: "Archive", + updated_at: "2024-01-01T00:00:00Z", + word_count: 5, + }]); + store.setDirectoriesForLocation(2, ["drafts"]); + + store.setSelectedLocation(2); + + expect(useAppStore.getState().documents).toStrictEqual([{ + location_id: 2, + rel_path: "archive.md", + title: "Archive", + updated_at: "2024-01-01T00:00:00Z", + word_count: 5, + }]); + expect(useAppStore.getState().directories).toStrictEqual(["drafts"]); + }); + it("focused layout hooks expose and update layout state", () => { const { result: chromeState } = renderHook(() => useLayoutChromeState()); const { result: chromeActions } = renderHook(() => useLayoutChromeActions()); diff --git a/src/__tests__/useWorkspaceSync.test.tsx b/src/__tests__/useWorkspaceSync.test.tsx index cbccd8f..6791789 100644 --- a/src/__tests__/useWorkspaceSync.test.tsx +++ b/src/__tests__/useWorkspaceSync.test.tsx @@ -1,5 +1,5 @@ import { useWorkspaceSync } from "$hooks/useWorkspaceSync"; -import { docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; +import { dirList, docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; import { resetAppStore, useAppStore } from "$state/stores/app"; import type { DocMeta } from "$types"; import { act, renderHook, waitFor } from "@testing-library/react"; @@ -31,6 +31,10 @@ vi.mock( }]); return { type: "None" }; }), + dirList: vi.fn((_locationId: number, onOk: (dirs: string[]) => void) => { + onOk([]); + return { type: "None" }; + }), startWatch: vi.fn((locationId: number) => ({ type: "StartWatch", locationId })), stopWatch: vi.fn((locationId: number) => ({ type: "StopWatch", locationId })), }), @@ -61,6 +65,7 @@ describe("useWorkspaceSync", () => { await waitFor(() => { expect(docList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); + expect(dirList).toHaveBeenCalledWith(1, expect.any(Function), expect.any(Function)); }); act(() => { diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 4476316..b5cb66e 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -12,7 +12,7 @@ import { } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; import { DocumentOperationDialog, @@ -109,6 +109,10 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm selectedDocPath, documents, directories, + documentsByLocation, + directoriesByLocation, + expandedLocationIds, + expandedDirectoriesByLocation, isLoading, refreshingLocationId, sidebarRefreshReason, @@ -119,10 +123,13 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm setActiveDropTarget, reorderFolderSortOrder, selectLocation, + toggleExpandedLocation, + toggleExpandedDirectory, + expandDirectories, + collapseDirectories, filenameVisibility, } = useSidebarState(); - const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); const [documentOperation, setDocumentOperation] = useState(null); const internalDnd = useSidebarInternalDnD({ @@ -140,37 +147,32 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], ); - const locationDirectories = useMemo(() => (selectedLocationId ? directories : []), [directories, selectedLocationId]); + const expandedLocationSet = useMemo(() => new Set(expandedLocationIds), [expandedLocationIds]); - const toggleLocation = useCallback((locationId: number) => { - setExpandedLocations((previous) => { - const next = new Set(previous); - if (next.has(locationId)) { - next.delete(locationId); - } else { - next.add(locationId); + useEffect(() => { + for (const location of locations) { + if (!expandedLocationSet.has(location.id) || refreshingLocationId === location.id) { + continue; } - return next; - }); - }, []); - const filteredDocuments = useMemo( - () => - filterText - ? locationDocuments.filter((doc) => - doc.title.toLowerCase().includes(filterText.toLowerCase()) - || doc.rel_path.toLowerCase().includes(filterText.toLowerCase()) - ) - : locationDocuments, - [locationDocuments, filterText], - ); - const filteredDirectories = useMemo( - () => - filterText - ? locationDirectories.filter((directoryPath) => directoryPath.toLowerCase().includes(filterText.toLowerCase())) - : locationDirectories, - [locationDirectories, filterText], - ); + const hasDocuments = Object.prototype.hasOwnProperty.call(documentsByLocation, location.id); + const hasDirectories = Object.prototype.hasOwnProperty.call(directoriesByLocation, location.id); + if (!hasDocuments || !hasDirectories) { + handleRefreshSidebar(location.id); + } + } + }, [ + directoriesByLocation, + documentsByLocation, + expandedLocationSet, + handleRefreshSidebar, + locations, + refreshingLocationId, + ]); + + const toggleLocation = useCallback((locationId: number) => { + toggleExpandedLocation(locationId); + }, [toggleExpandedLocation]); const handleAddDocument = useCallback(() => { if (!selectedLocationId) { @@ -215,8 +217,17 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm locations.map((location) => { const isSelectedLocation = selectedLocationId === location.id; const isRefreshingLocation = refreshingLocationId === location.id; - const locationDocs = isSelectedLocation ? filteredDocuments : EMPTY_DOCUMENTS; - const locationDirs = isSelectedLocation ? filteredDirectories : EMPTY_DIRECTORIES; + const locationDocs = documentsByLocation[location.id] ?? (isSelectedLocation ? documents : EMPTY_DOCUMENTS); + const locationDirs = directoriesByLocation[location.id] ?? (isSelectedLocation ? directories : EMPTY_DIRECTORIES); + const filteredLocationDocs = filterText + ? locationDocs.filter((doc) => + doc.title.toLowerCase().includes(filterText.toLowerCase()) + || doc.rel_path.toLowerCase().includes(filterText.toLowerCase()) + ) + : locationDocs; + const filteredLocationDirs = filterText + ? locationDirs.filter((directoryPath) => directoryPath.toLowerCase().includes(filterText.toLowerCase())) + : locationDirs; const isActiveDropLocation = activeDropTarget?.locationId === location.id; const activeDropDocumentPath = isActiveDropLocation && activeDropTarget?.targetType === "document" ? activeDropTarget.relPath @@ -237,10 +248,12 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm return { location, isSelected: isSelectedLocation, + selectedLocationId, selectedDocPath, - isExpanded: expandedLocations.has(location.id), - documents: locationDocs, - directories: locationDirs, + isExpanded: expandedLocationSet.has(location.id), + documents: filteredLocationDocs, + directories: filteredLocationDirs, + expandedDirectories: expandedDirectoriesByLocation[location.id] ?? [], filterText, isRefreshing: isRefreshingLocation, refreshReason: sidebarRefreshReason, @@ -260,14 +273,22 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm activeDragDocumentPath, suppressActiveDragSourceOpacity: internalDnd.suppressActiveDragSourceOpacity, folderSortOrder: folderSortOrderByLocation[location.id] ?? [], + onToggleDirectory: (path: string) => toggleExpandedDirectory(location.id, path), + onExpandDirectories: (paths: string[]) => expandDirectories(location.id, paths), + onCollapseDirectories: (paths: string[]) => collapseDirectories(location.id, paths), }; }), [ activeDropTarget, - expandedLocations, + collapseDirectories, filterText, folderSortOrderByLocation, - filteredDirectories, - filteredDocuments, + directories, + directoriesByLocation, + documents, + documentsByLocation, + expandedDirectoriesByLocation, + expandedLocationSet, + expandDirectories, internalDnd.isDraggingInternal, internalDnd.activeDragDocumentLocationId, internalDnd.activeDragDocumentPath, @@ -277,6 +298,7 @@ export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteIm selectedDocPath, selectedLocationId, sidebarRefreshReason, + toggleExpandedDirectory, ]); return ( diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index 2d885d8..ec8163c 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -31,10 +31,12 @@ export type SidebarDocumentActions = { type SidebarLocationItemProps = { location: LocationDescriptor; isSelected: boolean; + selectedLocationId?: number; selectedDocPath?: string; isExpanded: boolean; documents: DocMeta[]; directories: string[]; + expandedDirectories: string[]; filterText: string; isRefreshing: boolean; refreshReason: SidebarRefreshReason | null; @@ -50,6 +52,9 @@ type SidebarLocationItemProps = { activeDragDocumentPath?: string | null; suppressActiveDragSourceOpacity?: boolean; folderSortOrder?: string[]; + onToggleDirectory: (path: string) => void; + onExpandDirectories: (paths: string[]) => void; + onCollapseDirectories: (paths: string[]) => void; onRemoveLocation: (locationId: number) => void; onSelectLocation: (locationId: number) => void; onRefreshLocation: (locationId: number) => void; @@ -79,6 +84,7 @@ type FolderItemProps = { type SidebarTreeContextValue = { locationId: number; + selectedLocationId?: number; selectedDocPath?: string; filenameVisibility: boolean; documentActions: SidebarDocumentActions; @@ -206,14 +212,21 @@ const RefreshStatus = ({ reason }: { reason: SidebarRefreshReason | null }) => ( ); function TreeDocumentNode({ doc, level }: { doc: DocMeta; level: number }) { - const { selectedDocPath, filenameVisibility, documentActions, onOpenDocumentOperation, dropIndicators } = - useSidebarTreeContext(); + const { + locationId, + selectedLocationId, + selectedDocPath, + filenameVisibility, + documentActions, + onOpenDocumentOperation, + dropIndicators, + } = useSidebarTreeContext(); return ( >(new Set()); const [pendingSpringFolderPath, setPendingSpringFolderPath] = useState(null); const hoverExpandRef = useRef<{ path: string; timer: ReturnType } | null>(null); const dragExpandedSnapshotRef = useRef | null>(null); @@ -364,6 +381,7 @@ function SidebarLocationItemComponent( const skipAnimation = useSkipAnimation(); const showHighlight = Boolean(isExternalDropTarget) || Boolean(isInternalDropTarget); const showRootDropIndicator = Boolean(isInternalDropTarget) && !activeDropFolderPath && !activeDropDocumentPath; + const expandedDirectorySet = useMemo(() => new Set(expandedDirectories), [expandedDirectories]); const handleRemoveClick = useCallback(() => { onRemoveLocation(location.id); @@ -388,7 +406,7 @@ function SidebarLocationItemComponent( ]); useEffect(() => { - if (!selectedDocPath) { + if (!selectedDocPath || selectedLocationId !== location.id) { return; } @@ -397,30 +415,8 @@ function SidebarLocationItemComponent( return; } - setExpandedDirectories((previous) => { - const next = new Set(previous); - let changed = false; - for (const path of parentPaths) { - if (!next.has(path)) { - next.add(path); - changed = true; - } - } - return changed ? next : previous; - }); - }, [selectedDocPath]); - - const handleToggleDirectory = useCallback((path: string) => { - setExpandedDirectories((previous) => { - const next = new Set(previous); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); + onExpandDirectories(parentPaths); + }, [location.id, onExpandDirectories, selectedDocPath, selectedLocationId]); const clearHoverExpand = useCallback(() => { if (!hoverExpandRef.current) { @@ -435,7 +431,7 @@ function SidebarLocationItemComponent( useEffect(() => { if ( !isDragInProgress || activeDropFolderIntent !== "into" || !activeDropFolderPath - || expandedDirectories.has(activeDropFolderPath) + || expandedDirectorySet.has(activeDropFolderPath) ) { clearHoverExpand(); return; @@ -451,25 +447,27 @@ function SidebarLocationItemComponent( hoverExpandRef.current = { path: folderPath, timer: globalThis.setTimeout(() => { - setExpandedDirectories((previous) => { - if (previous.has(folderPath)) { - return previous; - } - if (!dragExpandedSnapshotRef.current?.has(folderPath)) { - autoExpandedDuringDragRef.current.add(folderPath); - } - return new Set([...previous, folderPath]); - }); + if (!dragExpandedSnapshotRef.current?.has(folderPath)) { + autoExpandedDuringDragRef.current.add(folderPath); + } + onExpandDirectories([folderPath]); setPendingSpringFolderPath((currentPath) => currentPath === folderPath ? null : currentPath); hoverExpandRef.current = null; }, 800), }; - }, [activeDropFolderIntent, activeDropFolderPath, clearHoverExpand, expandedDirectories, isDragInProgress]); + }, [ + activeDropFolderIntent, + activeDropFolderPath, + clearHoverExpand, + expandedDirectorySet, + isDragInProgress, + onExpandDirectories, + ]); useEffect(() => { if (isDragInProgress) { if (!dragExpandedSnapshotRef.current) { - dragExpandedSnapshotRef.current = new Set(expandedDirectories); + dragExpandedSnapshotRef.current = new Set(expandedDirectorySet); autoExpandedDuringDragRef.current = new Set(); } return; @@ -483,26 +481,19 @@ function SidebarLocationItemComponent( const autoExpanded = autoExpandedDuringDragRef.current; if (autoExpanded.size > 0) { - setExpandedDirectories((previous) => { - const next = new Set(previous); - for (const path of autoExpanded) { - if (!snapshot.has(path)) { - next.delete(path); - } - } - return next; - }); + onCollapseDirectories(Array.from(autoExpanded).filter((path) => !snapshot.has(path))); } dragExpandedSnapshotRef.current = null; autoExpandedDuringDragRef.current = new Set(); - }, [clearHoverExpand, expandedDirectories, isDragInProgress]); + }, [clearHoverExpand, expandedDirectorySet, isDragInProgress, onCollapseDirectories]); useEffect(() => () => clearHoverExpand(), [clearHoverExpand]); const treeContextValue = useMemo( () => ({ locationId: location.id, + selectedLocationId, selectedDocPath, filenameVisibility, documentActions: { @@ -538,6 +529,7 @@ function SidebarLocationItemComponent( location.id, openDocumentOperation, selectedDocPath, + selectedLocationId, suppressActiveDragSourceOpacity, ], ); @@ -551,12 +543,12 @@ function SidebarLocationItemComponent( key={node.path} node={node} level={1} - expandedDirectories={expandedDirectories} - onToggleDirectory={handleToggleDirectory} /> + expandedDirectories={expandedDirectorySet} + onToggleDirectory={onToggleDirectory} /> ) : ), - [documentTree.children, expandedDirectories, handleToggleDirectory], + [documentTree.children, expandedDirectorySet, onToggleDirectory], ); return ( @@ -577,7 +569,7 @@ function SidebarLocationItemComponent( onRefresh={handleRefresh} onRemove={handleRemoveClick} /> - {isExpanded && isSelected && ( + {isExpanded && (
{ let isCancelled = false; @@ -71,10 +85,26 @@ export function useSettingsSync(): void { logger.error(f("Failed to load global capture settings", { error })); })); + void runCmd(sidebarTreeGet((state) => { + if (isCancelled) { + return; + } + + setSidebarTreeState({ + expandedLocationIds: state.expanded_location_ids, + expandedDirectoriesByLocation: state.expanded_directories_by_location, + }); + setSidebarTreeHydrated(true); + }, () => { + if (!isCancelled) { + setSidebarTreeHydrated(true); + } + })); + return () => { isCancelled = true; }; - }, []); + }, [setSidebarTreeState]); useEffect(() => { if (!layoutSettingsHydrated) { @@ -104,4 +134,18 @@ export function useSettingsSync(): void { ), ); }, [layoutSettingsHydrated, styleCheckSettings]); + + useEffect(() => { + if (!sidebarTreeHydrated) { + return; + } + + void runCmd( + sidebarTreeSet( + { expanded_location_ids: expandedLocationIds, expanded_directories_by_location: expandedDirectoriesByLocation }, + () => {}, + () => {}, + ), + ); + }, [expandedDirectoriesByLocation, expandedLocationIds, sidebarTreeHydrated]); } diff --git a/src/hooks/controllers/useSidebarActions.ts b/src/hooks/controllers/useSidebarActions.ts index fd00b8d..7d92949 100644 --- a/src/hooks/controllers/useSidebarActions.ts +++ b/src/hooks/controllers/useSidebarActions.ts @@ -87,7 +87,7 @@ function mapDirectoryMovedRelPath(sourceDir: string, destinationDir: string, can } export function useSidebarActions() { - const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); + const { setSidebarRefreshState, setDocumentsForLocation, setDirectoriesForLocation } = useWorkspaceDocumentsActions(); const { setLocations, setSelectedLocation } = useWorkspaceLocationsActions(); const { tabs } = useTabsState(); const { applySessionState } = useTabsActions(); @@ -132,7 +132,7 @@ export function useSidebarActions() { }, [applySession]); const handleSelectDocument = useCallback((locationId: number, path: string) => { - const docTitle = useWorkspaceStore.getState().documents.find((doc) => + const docTitle = (useWorkspaceStore.getState().documentsByLocation[locationId] ?? []).find((doc) => doc.location_id === locationId && doc.rel_path === path )?.title; @@ -151,7 +151,11 @@ export function useSidebarActions() { return null; } - const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabs); + const relPath = buildDraftRelPath( + targetLocationId, + workspaceState.documentsByLocation[targetLocationId] ?? [], + tabs, + ); const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; openTab(docRef, getDraftTitle(relPath)); return docRef; @@ -165,7 +169,7 @@ export function useSidebarActions() { const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id; - if (!targetLocationId || workspaceState.selectedLocationId !== targetLocationId) { + if (!targetLocationId) { return; } @@ -173,12 +177,10 @@ export function useSidebarActions() { runCmd(dirList(targetLocationId, (nextDirectories) => { const latestState = useWorkspaceStore.getState(); - if (latestState.selectedLocationId !== targetLocationId) { - return; - } + const currentDirectories = latestState.directoriesByLocation[targetLocationId] ?? []; - if (!areDirectoriesEqual(latestState.directories, nextDirectories)) { - latestState.setDirectories(nextDirectories); + if (!areDirectoriesEqual(currentDirectories, nextDirectories)) { + setDirectoriesForLocation(targetLocationId, nextDirectories); } }, (error) => { logger.error(f("Failed to refresh sidebar directories", { locationId: targetLocationId, error })); @@ -186,22 +188,17 @@ export function useSidebarActions() { runCmd(docList(targetLocationId, (nextDocuments) => { const latestState = useWorkspaceStore.getState(); - if (latestState.selectedLocationId !== targetLocationId) { - if (latestState.refreshingLocationId === targetLocationId) { - latestState.setSidebarRefreshState(undefined, null); - } - return; - } + const currentDocuments = latestState.documentsByLocation[targetLocationId] ?? []; - if (nextDocuments.length === 0 && latestState.documents.length > 0 && attempt === 0) { + if (nextDocuments.length === 0 && currentDocuments.length > 0 && attempt === 0) { setTimeout(() => { handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); return; } - if (!areDocumentsEqual(latestState.documents, nextDocuments)) { - latestState.setDocuments(nextDocuments); + if (!areDocumentsEqual(currentDocuments, nextDocuments)) { + setDocumentsForLocation(targetLocationId, nextDocuments); } if (latestState.refreshingLocationId === targetLocationId) { @@ -221,7 +218,7 @@ export function useSidebarActions() { latestState.setSidebarRefreshState(undefined, null); } })); - }, [setSidebarRefreshState]); + }, [setDirectoriesForLocation, setDocumentsForLocation, setSidebarRefreshState]); const handleRenameDocument = useCallback((locationId: number, relPath: string, newName: string): Promise => { return new Promise((resolve) => { diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index f44508b..8eab529 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -99,7 +99,7 @@ export function useWorkspaceController() { const { locations, selectedLocationId, isLoadingLocations, sidebarFilter } = useWorkspaceLocationsState(); const { selectedDocPath, documents, isLoadingDocuments, refreshingLocationId, sidebarRefreshReason } = useWorkspaceDocumentsState(); - const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); + const { setSidebarRefreshState, setDocumentsForLocation, setDirectoriesForLocation } = useWorkspaceDocumentsActions(); const { setSidebarFilter, setSelectedLocation, setLocations } = useWorkspaceLocationsActions(); const { tabs, activeTabId, isSessionHydrated } = useTabsState(); const { applySessionState } = useTabsActions(); @@ -168,7 +168,7 @@ export function useWorkspaceController() { }, [applySession]); const handleSelectDocument = useCallback((locationId: number, path: string) => { - const docTitle = useWorkspaceStore.getState().documents.find((doc) => + const docTitle = (useWorkspaceStore.getState().documentsByLocation[locationId] ?? []).find((doc) => doc.location_id === locationId && doc.rel_path === path )?.title; @@ -221,7 +221,11 @@ export function useWorkspaceController() { return null; } - const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabs); + const relPath = buildDraftRelPath( + targetLocationId, + workspaceState.documentsByLocation[targetLocationId] ?? [], + tabs, + ); const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; openTab(docRef, getDraftTitle(relPath)); return docRef; @@ -235,7 +239,7 @@ export function useWorkspaceController() { const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id; - if (!targetLocationId || workspaceState.selectedLocationId !== targetLocationId) { + if (!targetLocationId) { return; } @@ -243,12 +247,10 @@ export function useWorkspaceController() { runCmd(dirList(targetLocationId, (nextDirectories) => { const latestState = useWorkspaceStore.getState(); - if (latestState.selectedLocationId !== targetLocationId) { - return; - } + const currentDirectories = latestState.directoriesByLocation[targetLocationId] ?? []; - if (!areDirectoriesEqual(latestState.directories, nextDirectories)) { - latestState.setDirectories(nextDirectories); + if (!areDirectoriesEqual(currentDirectories, nextDirectories)) { + setDirectoriesForLocation(targetLocationId, nextDirectories); } }, (error) => { logger.error(f("Failed to refresh sidebar directories", { locationId: targetLocationId, error })); @@ -256,22 +258,17 @@ export function useWorkspaceController() { runCmd(docList(targetLocationId, (nextDocuments) => { const latestState = useWorkspaceStore.getState(); - if (latestState.selectedLocationId !== targetLocationId) { - if (latestState.refreshingLocationId === targetLocationId) { - latestState.setSidebarRefreshState(undefined, null); - } - return; - } + const currentDocuments = latestState.documentsByLocation[targetLocationId] ?? []; - if (nextDocuments.length === 0 && latestState.documents.length > 0 && attempt === 0) { + if (nextDocuments.length === 0 && currentDocuments.length > 0 && attempt === 0) { setTimeout(() => { handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); return; } - if (!areDocumentsEqual(latestState.documents, nextDocuments)) { - latestState.setDocuments(nextDocuments); + if (!areDocumentsEqual(currentDocuments, nextDocuments)) { + setDocumentsForLocation(targetLocationId, nextDocuments); } if (latestState.refreshingLocationId === targetLocationId) { @@ -291,7 +288,7 @@ export function useWorkspaceController() { latestState.setSidebarRefreshState(undefined, null); } })); - }, [setSidebarRefreshState]); + }, [setDirectoriesForLocation, setDocumentsForLocation, setSidebarRefreshState]); const handleRenameDocument = useCallback((locationId: number, relPath: string, newName: string): Promise => { return new Promise((resolve) => { diff --git a/src/hooks/useWorkspaceSync.ts b/src/hooks/useWorkspaceSync.ts index bf5d460..343d940 100644 --- a/src/hooks/useWorkspaceSync.ts +++ b/src/hooks/useWorkspaceSync.ts @@ -1,9 +1,10 @@ -import { docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; +import { dirList, docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; import { useWorkspaceDocumentsActions, useWorkspaceLocationsActions, useWorkspaceLocationsState, } from "$state/selectors"; +import { useWorkspaceStore } from "$state/stores/workspace"; import { f } from "$utils/serialize"; import * as logger from "@tauri-apps/plugin-log"; import { useCallback, useEffect, useRef } from "react"; @@ -12,7 +13,14 @@ import { useBackendEvents } from "./useBackendEvents"; export function useWorkspaceSync(): void { const { locations, selectedLocationId } = useWorkspaceLocationsState(); const { setLocations, setLoadingLocations } = useWorkspaceLocationsActions(); - const { setDocuments, setLoadingDocuments, setSidebarRefreshState } = useWorkspaceDocumentsActions(); + const { + setDocuments, + setDirectories, + setDocumentsForLocation, + setDirectoriesForLocation, + setLoadingDocuments, + setSidebarRefreshState, + } = useWorkspaceDocumentsActions(); const hasLoadedLocationsRef = useRef(false); const loadLocations = useCallback((showLoading = true) => { @@ -42,7 +50,9 @@ export function useWorkspaceSync(): void { loadLocations(true); }, [loadLocations]); - const documentRequestRef = useRef(0); + const documentRequestRef = useRef>({}); + const directoryRequestRef = useRef>({}); + const pendingLoadCountRef = useRef>({}); const EXTERNAL_REFRESH_RETRY_DELAY_MS = 120; const EXTERNAL_REFRESH_MAX_ATTEMPTS = 3; const selectedLocationRef = useRef(selectedLocationId); @@ -57,18 +67,60 @@ export function useWorkspaceSync(): void { attempt?: number; }; - const loadDocuments = useCallback( + const finishLocationLoad = useCallback((locationId: number, source: "manual" | "external") => { + const pendingCount = pendingLoadCountRef.current[locationId]; + if (!pendingCount) { + return; + } + + if (pendingCount > 1) { + pendingLoadCountRef.current[locationId] = pendingCount - 1; + return; + } + + delete pendingLoadCountRef.current[locationId]; + + if (source === "manual" && selectedLocationRef.current === locationId) { + setLoadingDocuments(false); + } + + if (useWorkspaceStore.getState().refreshingLocationId === locationId) { + setSidebarRefreshState(undefined, null); + } + }, [setLoadingDocuments, setSidebarRefreshState]); + + const loadLocationTree = useCallback( (locationId: number, source: "manual" | "external" = "manual", hint?: ExternalRefreshHint) => { - const requestId = ++documentRequestRef.current; + documentRequestRef.current[locationId] = (documentRequestRef.current[locationId] ?? 0) + 1; + directoryRequestRef.current[locationId] = (directoryRequestRef.current[locationId] ?? 0) + 1; + const documentRequestId = documentRequestRef.current[locationId]; + const directoryRequestId = directoryRequestRef.current[locationId]; + pendingLoadCountRef.current[locationId] = 2; - if (source === "manual") { + if (source === "manual" && selectedLocationRef.current === locationId) { setLoadingDocuments(true); } else { setSidebarRefreshState(locationId, source); } + runCmd(dirList(locationId, (nextDirectories) => { + if (directoryRequestRef.current[locationId] !== directoryRequestId) { + return; + } + + setDirectoriesForLocation(locationId, nextDirectories); + finishLocationLoad(locationId, source); + }, (error) => { + if (directoryRequestRef.current[locationId] !== directoryRequestId) { + return; + } + + logger.error(f("Failed to load directories", { locationId, error })); + finishLocationLoad(locationId, source); + })); + runCmd(docList(locationId, (nextDocuments) => { - if (documentRequestRef.current !== requestId) { + if (documentRequestRef.current[locationId] !== documentRequestId) { return; } @@ -85,7 +137,7 @@ export function useWorkspaceSync(): void { if (shouldRetryExternalRefresh) { setTimeout(() => { - loadDocuments(locationId, "external", { + loadLocationTree(locationId, "external", { changedRelPath, changeKind: hint?.changeKind, attempt: externalAttempt + 1, @@ -93,35 +145,36 @@ export function useWorkspaceSync(): void { }, EXTERNAL_REFRESH_RETRY_DELAY_MS); } - setDocuments(nextDocuments); - if (source === "manual") { - setLoadingDocuments(false); - } - setSidebarRefreshState(undefined, null); + setDocumentsForLocation(locationId, nextDocuments); + finishLocationLoad(locationId, source); }, (error) => { - if (documentRequestRef.current !== requestId) { + if (documentRequestRef.current[locationId] !== documentRequestId) { return; } logger.error(f("Failed to load documents", { locationId, error })); - if (source === "manual") { - setLoadingDocuments(false); - } - setSidebarRefreshState(undefined, null); + finishLocationLoad(locationId, source); })); }, - [setDocuments, setLoadingDocuments, setSidebarRefreshState], + [ + finishLocationLoad, + setDirectoriesForLocation, + setDocumentsForLocation, + setLoadingDocuments, + setSidebarRefreshState, + ], ); useEffect(() => { if (!selectedLocationId) { setDocuments([]); + setDirectories([]); setLoadingDocuments(false); return; } - loadDocuments(selectedLocationId, "manual"); - }, [selectedLocationId, loadDocuments, setDocuments, setLoadingDocuments]); + loadLocationTree(selectedLocationId, "manual"); + }, [loadLocationTree, selectedLocationId, setDirectories, setDocuments, setLoadingDocuments]); const watchedLocationIdsRef = useRef>(new Set()); @@ -164,9 +217,13 @@ export function useWorkspaceSync(): void { loadLocations(false); }, onFilesystemChanged: (event) => { + const workspaceState = useWorkspaceStore.getState(); const currentLocationId = selectedLocationRef.current; - if (currentLocationId && event.location_id === currentLocationId) { - loadDocuments(currentLocationId, "external", { + const hasCachedTree = Object.prototype.hasOwnProperty.call(workspaceState.documentsByLocation, event.location_id) + || Object.prototype.hasOwnProperty.call(workspaceState.directoriesByLocation, event.location_id); + + if ((currentLocationId && event.location_id === currentLocationId) || hasCachedTree) { + loadLocationTree(event.location_id, "external", { changedRelPath: event.rel_path, changeKind: event.change_kind, attempt: 0, diff --git a/src/ports/commands.ts b/src/ports/commands.ts index de25e7c..8e1ad28 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -43,6 +43,7 @@ import type { GlobalCaptureSubmitParams, GlobalCaptureValidateShortcutParams, LocParams, + PersistedSidebarTreeState, PersistedStyleCheckSettings, PostGetMarkdownParams, PostListParams, @@ -62,6 +63,8 @@ import type { SessionReorderTabsParams, SessionTabIdParams, SessionUpdateTabDocParams, + SidebarTreeGetParams, + SidebarTreeSetParams, StringCreateParams, StringDeleteParams, StringGetParams, @@ -365,6 +368,14 @@ export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams("ui_layout_set", { settings }, onOk, onErr); } +export function sidebarTreeGet(...[onOk, onErr]: SidebarTreeGetParams): Cmd { + return invokeCmd("sidebar_tree_get", {}, onOk, onErr); +} + +export function sidebarTreeSet(...[state, onOk, onErr]: SidebarTreeSetParams): Cmd { + return invokeCmd("sidebar_tree_set", { stateValue: state }, onOk, onErr); +} + export function sessionGet(...[onOk, onErr]: SessionParams): Cmd { return invokeCmd("session_get", {}, onOk, onErr); } diff --git a/src/ports/types.ts b/src/ports/types.ts index 509d650..cce0993 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -55,6 +55,11 @@ export type UiLayoutSettings = { markdown_preview_style: MarkdownPreviewStyle; }; +export type PersistedSidebarTreeState = { + expanded_location_ids: number[]; + expanded_directories_by_location: Record; +}; + export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; export type PersistedStyleCheckSettings = { @@ -164,6 +169,11 @@ export type UiLayoutSetParams = Parameters< (settings: UiLayoutSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void >; +export type SidebarTreeGetParams = LocParams; +export type SidebarTreeSetParams = Parameters< + (state: PersistedSidebarTreeState, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + export type SessionOpenTabParams = Parameters< (docRef: DocRef, title: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 3d08fff..5ad9d5e 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -148,6 +148,10 @@ export const useWorkspaceDocumentsState = () => selectedDocPath: state.selectedDocPath, documents: state.documents, directories: state.directories, + documentsByLocation: state.documentsByLocation, + directoriesByLocation: state.directoriesByLocation, + expandedLocationIds: state.expandedLocationIds, + expandedDirectoriesByLocation: state.expandedDirectoriesByLocation, isLoadingDocuments: state.isLoadingDocuments, refreshingLocationId: state.refreshingLocationId, sidebarRefreshReason: state.sidebarRefreshReason, @@ -164,6 +168,13 @@ export const useWorkspaceDocumentsActions = () => setSelectedDocPath: state.setSelectedDocPath, setDocuments: state.setDocuments, setDirectories: state.setDirectories, + setDocumentsForLocation: state.setDocumentsForLocation, + setDirectoriesForLocation: state.setDirectoriesForLocation, + setSidebarTreeState: state.setSidebarTreeState, + toggleExpandedLocation: state.toggleExpandedLocation, + toggleExpandedDirectory: state.toggleExpandedDirectory, + expandDirectories: state.expandDirectories, + collapseDirectories: state.collapseDirectories, setLoadingDocuments: state.setLoadingDocuments, setSidebarRefreshState: state.setSidebarRefreshState, setExternalDropTarget: state.setExternalDropTarget, @@ -384,6 +395,10 @@ export const useSidebarState = () => { selectedDocPath: state.selectedDocPath, documents: state.documents, directories: state.directories, + documentsByLocation: state.documentsByLocation, + directoriesByLocation: state.directoriesByLocation, + expandedLocationIds: state.expandedLocationIds, + expandedDirectoriesByLocation: state.expandedDirectoriesByLocation, isLoadingLocations: state.isLoadingLocations, isLoadingDocuments: state.isLoadingDocuments, refreshingLocationId: state.refreshingLocationId, @@ -397,6 +412,13 @@ export const useSidebarState = () => { selectLocation: state.setSelectedLocation, setDocuments: state.setDocuments, setDirectories: state.setDirectories, + setDocumentsForLocation: state.setDocumentsForLocation, + setDirectoriesForLocation: state.setDirectoriesForLocation, + setSidebarTreeState: state.setSidebarTreeState, + toggleExpandedLocation: state.toggleExpandedLocation, + toggleExpandedDirectory: state.toggleExpandedDirectory, + expandDirectories: state.expandDirectories, + collapseDirectories: state.collapseDirectories, setExternalDropTarget: state.setExternalDropTarget, setActiveDropTarget: state.setActiveDropTarget, reorderFolderSortOrder: state.reorderFolderSortOrder, @@ -409,6 +431,10 @@ export const useSidebarState = () => { selectedDocPath: workspaceState.selectedDocPath, documents: workspaceState.documents, directories: workspaceState.directories, + documentsByLocation: workspaceState.documentsByLocation, + directoriesByLocation: workspaceState.directoriesByLocation, + expandedLocationIds: workspaceState.expandedLocationIds, + expandedDirectoriesByLocation: workspaceState.expandedDirectoriesByLocation, isLoading: workspaceState.isLoadingLocations || workspaceState.isLoadingDocuments, refreshingLocationId: workspaceState.refreshingLocationId, sidebarRefreshReason: workspaceState.sidebarRefreshReason, @@ -421,6 +447,13 @@ export const useSidebarState = () => { selectLocation: workspaceState.selectLocation, setDocuments: workspaceState.setDocuments, setDirectories: workspaceState.setDirectories, + setDocumentsForLocation: workspaceState.setDocumentsForLocation, + setDirectoriesForLocation: workspaceState.setDirectoriesForLocation, + setSidebarTreeState: workspaceState.setSidebarTreeState, + toggleExpandedLocation: workspaceState.toggleExpandedLocation, + toggleExpandedDirectory: workspaceState.toggleExpandedDirectory, + expandDirectories: workspaceState.expandDirectories, + collapseDirectories: workspaceState.collapseDirectories, toggleSidebarCollapsed: layoutState.toggleSidebarCollapsed, filenameVisibility: layoutState.filenameVisibility, setExternalDropTarget: workspaceState.setExternalDropTarget, diff --git a/src/state/stores/tabs.ts b/src/state/stores/tabs.ts index 958eb34..f8412d5 100644 --- a/src/state/stores/tabs.ts +++ b/src/state/stores/tabs.ts @@ -13,9 +13,13 @@ export const useTabsStore = create()((set) => ({ set({ tabs: session.tabs, activeTabId: session.activeTabId, isSessionHydrated: true }); const activeTab = session.tabs.find((tab) => tab.id === session.activeTabId) ?? null; + const workspaceState = useWorkspaceStore.getState(); + const selectedLocationId = activeTab?.docRef.location_id; useWorkspaceStore.setState({ - selectedLocationId: activeTab?.docRef.location_id, + selectedLocationId, selectedDocPath: activeTab?.docRef.rel_path, + documents: selectedLocationId ? workspaceState.documentsByLocation[selectedLocationId] ?? [] : [], + directories: selectedLocationId ? workspaceState.directoriesByLocation[selectedLocationId] ?? [] : [], }); }, })); diff --git a/src/state/stores/workspace.ts b/src/state/stores/workspace.ts index c1e03d0..fe1f0a8 100644 --- a/src/state/stores/workspace.ts +++ b/src/state/stores/workspace.ts @@ -26,6 +26,10 @@ export const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => selectedDocPath: undefined, documents: [], directories: [], + documentsByLocation: {}, + directoriesByLocation: {}, + expandedLocationIds: [], + expandedDirectoriesByLocation: {}, isLoadingDocuments: false, refreshingLocationId: undefined, sidebarRefreshReason: null, @@ -51,14 +55,125 @@ export const useWorkspaceStore = create()((set) => ({ setSidebarFilter: (value) => set({ sidebarFilter: value }), setLocations: (locations) => { - set((state) => ({ locations, selectedLocationId: state.selectedLocationId ?? locations[0]?.id })); + set((state) => { + const validLocationIds = new Set(locations.map((location) => location.id)); + const selectedLocationId = state.selectedLocationId && validLocationIds.has(state.selectedLocationId) + ? state.selectedLocationId + : locations[0]?.id; + + const documentsByLocation = Object.fromEntries( + Object.entries(state.documentsByLocation).filter(([locationId]) => validLocationIds.has(Number(locationId))), + ); + const directoriesByLocation = Object.fromEntries( + Object.entries(state.directoriesByLocation).filter(([locationId]) => validLocationIds.has(Number(locationId))), + ); + const expandedDirectoriesByLocation = Object.fromEntries( + Object.entries(state.expandedDirectoriesByLocation).filter(([locationId]) => + validLocationIds.has(Number(locationId)) + ), + ); + + return { + locations, + selectedLocationId, + documents: selectedLocationId ? documentsByLocation[selectedLocationId] ?? [] : [], + directories: selectedLocationId ? directoriesByLocation[selectedLocationId] ?? [] : [], + documentsByLocation, + directoriesByLocation, + expandedLocationIds: state.expandedLocationIds.filter((locationId) => validLocationIds.has(locationId)), + expandedDirectoriesByLocation, + }; + }); }, setLoadingLocations: (value) => set({ isLoadingLocations: value }), - setSelectedLocation: (locationId) => set({ selectedLocationId: locationId, selectedDocPath: undefined }), + setSelectedLocation: (locationId) => + set((state) => ({ + selectedLocationId: locationId, + selectedDocPath: undefined, + documents: locationId ? state.documentsByLocation[locationId] ?? [] : [], + directories: locationId ? state.directoriesByLocation[locationId] ?? [] : [], + })), setSelectedDocPath: (path) => set({ selectedDocPath: path }), - setDocuments: (documents) => set({ documents }), - setDirectories: (directories) => set({ directories }), + setDocuments: (documents) => + set((state) => { + const selectedLocationId = state.selectedLocationId; + if (!selectedLocationId) { + return { documents }; + } + + return { documents, documentsByLocation: { ...state.documentsByLocation, [selectedLocationId]: documents } }; + }), + setDirectories: (directories) => + set((state) => { + const selectedLocationId = state.selectedLocationId; + if (!selectedLocationId) { + return { directories }; + } + + return { + directories, + directoriesByLocation: { ...state.directoriesByLocation, [selectedLocationId]: directories }, + }; + }), + setDocumentsForLocation: (locationId, documents) => + set((state) => ({ + documentsByLocation: { ...state.documentsByLocation, [locationId]: documents }, + ...(state.selectedLocationId === locationId ? { documents } : {}), + })), + setDirectoriesForLocation: (locationId, directories) => + set((state) => ({ + directoriesByLocation: { ...state.directoriesByLocation, [locationId]: directories }, + ...(state.selectedLocationId === locationId ? { directories } : {}), + })), + setSidebarTreeState: (sidebarTreeState) => + set({ + expandedLocationIds: sidebarTreeState.expandedLocationIds, + expandedDirectoriesByLocation: sidebarTreeState.expandedDirectoriesByLocation, + }), + toggleExpandedLocation: (locationId) => + set((state) => ({ + expandedLocationIds: state.expandedLocationIds.includes(locationId) + ? state.expandedLocationIds.filter((currentId) => currentId !== locationId) + : [...state.expandedLocationIds, locationId], + })), + toggleExpandedDirectory: (locationId, path) => set((state) => { + const currentPaths = state.expandedDirectoriesByLocation[locationId] ?? []; + const nextPaths = currentPaths.includes(path) + ? currentPaths.filter((currentPath) => currentPath !== path) + : [...currentPaths, path]; + + return { expandedDirectoriesByLocation: { ...state.expandedDirectoriesByLocation, [locationId]: nextPaths } }; + }), + expandDirectories: (locationId, paths) => + set((state) => { + if (paths.length === 0) { + return state; + } + + const currentPaths = state.expandedDirectoriesByLocation[locationId] ?? []; + const nextPaths = Array.from(new Set([...currentPaths, ...paths])); + if (nextPaths.length === currentPaths.length) { + return state; + } + + return { expandedDirectoriesByLocation: { ...state.expandedDirectoriesByLocation, [locationId]: nextPaths } }; + }), + collapseDirectories: (locationId, paths) => + set((state) => { + if (paths.length === 0) { + return state; + } + + const currentPaths = state.expandedDirectoriesByLocation[locationId] ?? []; + const collapsedPaths = new Set(paths); + const nextPaths = currentPaths.filter((currentPath) => !collapsedPaths.has(currentPath)); + if (nextPaths.length === currentPaths.length) { + return state; + } + + return { expandedDirectoriesByLocation: { ...state.expandedDirectoriesByLocation, [locationId]: nextPaths } }; + }), setLoadingDocuments: (value) => set({ isLoadingDocuments: value }), setSidebarRefreshState: (locationId, reason: SidebarRefreshReason | null = null) => set({ refreshingLocationId: locationId, sidebarRefreshReason: locationId === undefined ? null : reason }), diff --git a/src/state/types.ts b/src/state/types.ts index e50366a..e611754 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -12,6 +12,7 @@ import type { PatternCategory, SearchHit, SessionState, + SidebarTreeState, StyleCheckPattern, StyleCheckSettings, Tab, @@ -133,6 +134,10 @@ export type WorkspaceDocumentsState = { selectedDocPath?: string; documents: DocMeta[]; directories: string[]; + documentsByLocation: Record; + directoriesByLocation: Record; + expandedLocationIds: SidebarTreeState["expandedLocationIds"]; + expandedDirectoriesByLocation: SidebarTreeState["expandedDirectoriesByLocation"]; isLoadingDocuments: boolean; refreshingLocationId?: number; sidebarRefreshReason: SidebarRefreshReason | null; @@ -154,6 +159,13 @@ export type WorkspaceDocumentsActions = { setSelectedDocPath: (path?: string) => void; setDocuments: (documents: DocMeta[]) => void; setDirectories: (directories: string[]) => void; + setDocumentsForLocation: (locationId: number, documents: DocMeta[]) => void; + setDirectoriesForLocation: (locationId: number, directories: string[]) => void; + setSidebarTreeState: (state: SidebarTreeState) => void; + toggleExpandedLocation: (locationId: number) => void; + toggleExpandedDirectory: (locationId: number, path: string) => void; + expandDirectories: (locationId: number, paths: string[]) => void; + collapseDirectories: (locationId: number, paths: string[]) => void; setLoadingDocuments: (value: boolean) => void; setSidebarRefreshState: (locationId?: number, reason?: SidebarRefreshReason | null) => void; setExternalDropTarget: (locationId?: number, folderPath?: string) => void; diff --git a/src/types.ts b/src/types.ts index 1e1f32b..ac8bb73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,10 @@ export type DocMeta = { export type DocContent = { text: string; meta: DocMeta }; export type LocationDescriptor = { id: LocationId; name: string; root_path: string; added_at: string }; +export type SidebarTreeState = { + expandedLocationIds: number[]; + expandedDirectoriesByLocation: Record; +}; export type Heading = { level: number; text: string; anchor: string | null };