diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 0a516f5..f08e282 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1560,7 +1560,6 @@ mod tests { editor_font_size: 18, editor_font_family: "Monaspace Neon".to_string(), calm_ui_enabled: false, - calm_ui_auto_hide: false, calm_ui_focus_mode: false, focus_typewriter_scrolling_enabled: false, focus_dimming_mode: FocusDimmingMode::Paragraph, @@ -1598,7 +1597,6 @@ mod tests { assert_eq!(loaded.editor_font_size, 16); assert_eq!(loaded.editor_font_family, "IBM Plex Mono"); assert!(loaded.calm_ui_enabled); - assert!(loaded.calm_ui_auto_hide); assert!(loaded.calm_ui_focus_mode); assert!(loaded.focus_typewriter_scrolling_enabled); assert_eq!(loaded.focus_dimming_mode, FocusDimmingMode::Sentence); diff --git a/crates/store/src/settings.rs b/crates/store/src/settings.rs index f677033..75c8217 100644 --- a/crates/store/src/settings.rs +++ b/crates/store/src/settings.rs @@ -25,10 +25,6 @@ fn default_calm_ui_enabled() -> bool { true } -fn default_calm_ui_auto_hide() -> bool { - true -} - fn default_calm_ui_focus_mode() -> bool { true } @@ -90,8 +86,6 @@ pub struct UiLayoutSettings { pub editor_font_family: String, #[serde(default = "default_calm_ui_enabled")] pub calm_ui_enabled: bool, - #[serde(default = "default_calm_ui_auto_hide")] - pub calm_ui_auto_hide: bool, #[serde(default = "default_calm_ui_focus_mode")] pub calm_ui_focus_mode: bool, #[serde(default = "default_focus_typewriter_scrolling_enabled")] @@ -112,7 +106,6 @@ impl Default for UiLayoutSettings { editor_font_size: default_editor_font_size(), editor_font_family: default_editor_font_family(), calm_ui_enabled: default_calm_ui_enabled(), - calm_ui_auto_hide: default_calm_ui_auto_hide(), calm_ui_focus_mode: default_calm_ui_focus_mode(), focus_typewriter_scrolling_enabled: default_focus_typewriter_scrolling_enabled(), focus_dimming_mode: default_focus_dimming_mode(), diff --git a/docs/architecture.md b/docs/architecture.md index 317de50..d1cb9dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,11 +16,11 @@ Canonical content lives in user-selected folders ("locations"). The app database ### State Layers -- Zustand (`src/state/appStore.ts`) is the primary app store: +- Zustand (`src/state/stores/app.ts`) is the primary app store: - layout/editor presentation state - workspace state (locations, docs, selection) - tabs and PDF export status -- Jotai (`src/state/searchAtoms.ts`) is used for localized search UI state. +- Jotai (`src/state/atoms/search.ts` and `src/state/atoms/ui.ts`) is used for localized UI orchestration state. ### Command Boundary (`ports`) @@ -103,9 +103,6 @@ Current NLP/writing-assist features are frontend-first: Style-check settings are persisted (`style_check_get/set` + SQLite `app_settings`), while POS highlighting is currently session state. -## Runtime Lifecycle (High Level) +## Runtime Lifecycle -1. Tauri boots plugins, opens store, reconciles locations/indexes, and emits startup events. -2. Frontend hydrates persisted UI/style settings and loads locations/documents. -3. Selecting/opening/saving docs goes through typed commands. -4. Watcher events keep the document index and UI views synchronized with external file changes. +See [`docs/lifecycle.md`](./lifecycle.md) for the canonical startup/runtime lifecycle. diff --git a/docs/lifecycle.md b/docs/lifecycle.md new file mode 100644 index 0000000..7e95e03 --- /dev/null +++ b/docs/lifecycle.md @@ -0,0 +1,138 @@ +# App Lifecycle + +This document is the canonical runtime lifecycle reference for the desktop app. +Architecture/state/pdf docs intentionally avoid duplicating this sequence. + +## 1. Process Boot and Route Selection + +Entry point: `src/main.tsx`. + +At startup, the frontend: + +1. Initializes frontend logging. +2. Installs global error handlers (`error`, `unhandledrejection`). +3. Reads the current Tauri window label via `getCurrentWindow().label`. +4. Applies an initial hash route with `applyInitialRoute(windowLabel)`. +5. Renders `AppRouter`. + +Router: `src/routes/AppRouter.tsx` (hash router). + +- `#/` -> `App` (main editor app). +- `#/quick-capture` -> `QuickCaptureApp` (compact capture window). +- fallback -> not-found route. + +## 2. Main App Mount (`App`) + +Primary composition root: `src/App.tsx`. + +On mount, `App` sets up: + +- Editor state machine (`useEditor`). +- Preview orchestration (`usePreview`). +- Workspace synchronization (`useWorkspaceSync`). +- Layout hotkeys (`useLayoutHotkeys`). +- Workspace/document/tab actions (`useWorkspaceController`). +- Typing activity tracking (`useTypingActivity`). + +## 3. Settings Hydration and Persistence Loop + +Hydration effect (runs once): + +- `uiLayoutGet` -> layout/editor/focus settings. +- `styleCheckGet` -> writer tools style-check settings. +- `globalCaptureGet` -> quick-capture settings atom. + +After hydration (`layoutSettingsHydrated = true`): + +- UI/layout changes are persisted via `uiLayoutSet`. +- Style-check changes are persisted via `styleCheckSet`. + +This protects against writing defaults before persisted values load. + +## 4. Workspace and Document Restore Lifecycle + +Startup restore runs once when: + +- locations are loaded, +- at least one location exists, +- no tabs are open yet. + +Restore sequence: + +1. `sessionLastDocGet`. +2. Validate location exists. +3. `docExists` for the remembered file. +4. If valid, select/open that document tab. +5. Otherwise, create a fallback blank draft tab. + +After restore completes, active doc references are saved with `sessionLastDocSet`. + +## 5. Active Tab and Editor Lifecycle + +`App` reacts to active tab changes: + +- Calls `openDoc(activeDocRef)` to load editor content. +- Updates preview source doc (`setPreviewDoc`). +- Debounced markdown preview render (`renderPreview`) after 300ms text changes. +- Marks active tab modified state from editor save status (`Dirty` -> modified badge). + +Save lifecycle: + +1. If current buffer has no `docRef`, create draft path in selected location. +2. Dispatch `SaveRequested`. +3. Port command executes `doc_save`. +4. Save status transitions (`Saving` -> `Saved`/`Error`). + +## 6. UI State Lifecycle (Jotai + Zustand) + +UI state now uses both stores: + +- Zustand (`src/state/stores/app.ts`): structural app/workspace/editor/view/persisted settings and PDF export status. +- Jotai (`src/state/atoms/*.ts`): localized UI orchestration state. + +Current Jotai UI atoms: + +- `layoutSettingsOpenAtom`: settings panel visibility. +- `pdfExportDialogOpenAtom`: PDF dialog visibility. +- `pdfExportOptionsAtom`: PDF dialog options (session scoped). +- `globalCaptureSettingsAtom`: quick-capture settings mirror used by UI. +- `setQuickCaptureEnabledAtom`: optimistic quick-capture toggle with rollback on persist failure. +- search atoms in `src/state/atoms/search.ts` for search query/results/filters/loading. + +## 7. Search Overlay Lifecycle + +Search overlay flow: + +1. Header toggles search visibility through layout chrome state. +2. `SearchOverlay` uses `useSearchController`. +3. Controller debounces query, calls `searchDocuments`, and guards stale responses. +4. Selecting a hit opens/selects the target tab and closes overlay. + +## 8. PDF Export Lifecycle + +Open and execute: + +1. Toolbar opens dialog (`pdfExportDialogOpenAtom`) only if an active tab exists. +2. Dialog options are edited through `pdfExportOptionsAtom`. +3. Export click requests backend AST (`renderMarkdownForPdf`). +4. `usePdfExport` renders PDF (`@react-pdf/renderer`) and writes via Tauri dialogs/fs plugin. +5. Export state (`isExportingPdf`, `pdfExportError`) is tracked in Zustand PDF slice. +6. On cancel/close, dialog closes and PDF export status resets. + +## 9. Quick Capture Route Lifecycle + +`QuickCaptureApp` (`#/quick-capture`): + +1. Loads global capture settings with `globalCaptureGet`. +2. Submits capture via `globalCaptureSubmit`. +3. Closes the window when backend result indicates `shouldClose`. + +This route is intended for the dedicated quick-capture Tauri window. + +## 10. Error and Diagnostics Lifecycle + +Runtime diagnostics are continuously handled by: + +- frontend logger initialization, +- global JS error/rejection handlers, +- backend event alerts (`BackendAlerts`) for missing locations and conflicts. diff --git a/docs/nlp.md b/docs/nlp.md index 22f9a9c..d66a488 100644 --- a/docs/nlp.md +++ b/docs/nlp.md @@ -50,7 +50,7 @@ last_updated: 2026-02-23 - Style-check settings are persisted through Tauri commands: - `style_check_get` / `style_check_set` (`src-tauri/src/commands.rs`) - Stored in SQLite `app_settings` as key `style_check` (`crates/store/src/lib.rs`) -- Frontend hydration/save loop happens in `src/App.tsx` (load on startup, save on setting changes after hydration). +- Runtime hydration/persistence sequence is documented in [`docs/lifecycle.md`](./lifecycle.md). ## POS Highlighting @@ -71,6 +71,6 @@ last_updated: 2026-02-23 ## State Boundaries -- Runtime UI/state source: Zustand (`src/state/appStore.ts`). +- Runtime UI/state source: Zustand (`src/state/stores/app.ts`). - Search state is separate (Jotai) and unrelated to writer NLP. - Persistence for NLP-related settings currently covers style-check only; POS state remains session-scoped. diff --git a/docs/pdf.md b/docs/pdf.md index 80c8984..e464186 100644 --- a/docs/pdf.md +++ b/docs/pdf.md @@ -23,10 +23,9 @@ last_updated: 2026-02-23 - UI entry point is `PdfExportDialog` (`src/components/pdf/ExportDialog/ExportDialog.tsx`), opened from the toolbar. -- Export flow in `src/App.tsx`: -- Request AST from backend via `renderMarkdownForPdf(...)` (ports command -> Tauri command). -- Pass AST + export options into `usePdfExport()`. +- Export orchestration lives in `src/App.tsx` (`renderMarkdownForPdf(...)` + `usePdfExport()`). - `usePdfExport` (`src/hooks/usePdfExport.tsx`) renders a React PDF document (`MarkdownPdfDocument`) with `@react-pdf/renderer`, then writes bytes to a user-selected path via Tauri plugins. +- Full runtime sequence is documented in [`docs/lifecycle.md`](./lifecycle.md). ### Rendering Pipeline @@ -48,10 +47,10 @@ last_updated: 2026-02-23 - Output is written using: - `@tauri-apps/plugin-dialog` (`save`) for destination - `@tauri-apps/plugin-fs` (`writeFile`) for bytes -- Export lifecycle state is tracked in Zustand (`isExportingPdf`, `pdfExportError` in `src/state/appStore.ts`) via `startPdfExport`, `finishPdfExport`, and `failPdfExport`. +- Export lifecycle state is tracked in Zustand (`isExportingPdf`, `pdfExportError` in `src/state/stores/app.ts`) via `startPdfExport`, `finishPdfExport`, and `failPdfExport`. - If the user cancels the save dialog, export exits cleanly without writing a file. ### Persistence Notes - PDF files themselves are persisted to the chosen filesystem path. -- Export dialog options are local React state (session-only) and are not currently persisted in app settings. +- Export dialog options are kept in Jotai (`pdfExportOptionsAtom`) and are session-only (not persisted in app settings). diff --git a/docs/state.md b/docs/state.md index e8bddd9..79527e1 100644 --- a/docs/state.md +++ b/docs/state.md @@ -7,7 +7,7 @@ last_updated: 2026-02-23 ### Zustand -- `src/state/appStore.ts` is the main client state container. +- `src/state/stores/app.ts` is the main client state container. - It is organized as composable slices (layout, editor presentation, view mode, writer tools, workspace, tabs, PDF export) and merged into one `useAppStore`. - Domain actions in the store are intentionally cross-slice when needed @@ -18,10 +18,11 @@ last_updated: 2026-02-23 ### Jotai -- Jotai is used for search UI state in `src/state/searchAtoms.ts`. -- Atoms hold query/results/loading/filters plus simple derived/reset atoms. -- This state is intentionally separate from Zustand because search interactions - are localized and ephemeral. +- Jotai is used for UI orchestration atoms in `src/state/atoms/`. +- Search atoms: `src/state/atoms/search.ts`. +- Dialog/export/quick-capture UI atoms: `src/state/atoms/ui.ts`. +- Atoms include query/results/loading/filters plus dialog/export/quick-capture UI controls. +- This state is intentionally separate from Zustand because these concerns are localized and orchestration-focused. ### Elmish/Ports @@ -37,7 +38,7 @@ last_updated: 2026-02-23 ### Globals - A few module-scoped values are used for non-UI implementation details: -- `nextTabId` in `src/state/appStore.ts` generates runtime tab ids and is reset +- `nextTabId` in `src/state/stores/app.ts` generates runtime tab ids and is reset by `resetAppStore()`. - Request/version refs in hooks (for example `documentRequestRef` in `useWorkspaceSync`) prevent stale async responses from overwriting newer state. diff --git a/package.json b/package.json index 8d071e1..ce2b3fc 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", "clsx": "^2.1.1", - "jotai": "^2.17.1", "motion": "^12.34.2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa65edd..75f71d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,9 +65,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - jotai: - specifier: ^2.17.1 - version: 2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) motion: specifier: ^12.34.2 version: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1462,24 +1459,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jotai@2.17.1: - resolution: {integrity: sha512-TFNZZDa/0ewCLQyRC/Sq9crtixNj/Xdf/wmj9631xxMuKToVJZDbqcHIYN0OboH+7kh6P6tpIK7uKWClj86PKw==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@babel/core': '>=7.0.0' - '@babel/template': '>=7.0.0' - '@types/react': '>=17.0.0' - react: '>=17.0.0' - peerDependenciesMeta: - '@babel/core': - optional: true - '@babel/template': - optional: true - '@types/react': - optional: true - react: - optional: true - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3280,13 +3259,6 @@ snapshots: jiti@2.6.1: {} - jotai@2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): - optionalDependencies: - '@babel/core': 7.29.0 - '@babel/template': 7.28.6 - '@types/react': 19.2.14 - react: 19.2.4 - js-tokens@10.0.0: {} js-tokens@4.0.0: {} diff --git a/src/App.tsx b/src/App.tsx index 10e550a..c05b3da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,4 @@ import { Button } from "$components/Button"; -import { logger } from "$logger"; -import type { PdfExportOptions, PdfRenderResult } from "$pdf/types"; -import { - docExists, - globalCaptureGet, - renderMarkdownForPdf, - runCmd, - sessionLastDocGet, - sessionLastDocSet, - styleCheckGet, - styleCheckSet, - uiLayoutGet, - uiLayoutSet, -} from "$ports"; -import { globalCaptureSettingsAtom, pdfExportDialogOpenAtom } from "$state/atoms/ui"; -import { stateToLayoutSettings, uiSettingsToCalmUI, uiSettingsToFocusMode } from "$state/helpers"; -import type { DocRef } from "$types"; -import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; -import { useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppHeaderBar } from "./components/layout/AppHeaderBar"; import { BackendAlerts } from "./components/layout/BackendAlerts"; import { FocusModePanel } from "./components/layout/FocusModePanel"; @@ -26,24 +6,7 @@ import { LayoutSettingsPanel } from "./components/layout/LayoutSettingsPanel"; import { SearchOverlay } from "./components/layout/SearchOverlay"; import { WorkspacePanel } from "./components/layout/WorkspacePanel"; import { PdfExportDialog } from "./components/pdf/ExportDialog/ExportDialog"; -import { useEditor } from "./hooks/useEditor"; -import { useLayoutHotkeys } from "./hooks/useLayoutHotkeys"; -import { usePdfExport } from "./hooks/usePdfExport"; -import { usePreview } from "./hooks/usePreview"; -import { useTypingActivity } from "./hooks/useTypingActivity"; -import { useWorkspaceController } from "./hooks/useWorkspaceController"; -import { useWorkspaceSync } from "./hooks/useWorkspaceSync"; -import { - useEditorPresentationActions, - useEditorPresentationState, - useLayoutChromeActions, - useLayoutChromeState, - usePdfExportActions, - useViewModeActions, - useViewModeState, - useWriterToolsActions, - useWriterToolsState, -} from "./state/stores/app"; +import { useAppController } from "./hooks/controllers/useAppController"; const ShowButton = ({ clickHandler, title, label }: { clickHandler: () => void; title: string; label: string }) => ( + + ); export const AppHeaderBar = () => { - const { tabBarCollapsed, toggleSidebarCollapsed, toggleTabBarCollapsed, setShowSearch } = useAppHeaderBarState(); + const { + tabBarCollapsed, + statusBarCollapsed, + toggleSidebarCollapsed, + toggleTabBarCollapsed, + toggleStatusBarCollapsed, + setShowSearch, + } = useAppHeaderBarState(); const { viewportWidth, isCompact, isNarrow } = useViewportTier(); const handleOpenSearch = useCallback(() => { @@ -76,7 +114,9 @@ export const AppHeaderBar = () => { diff --git a/src/components/layout/FocusModePanel.tsx b/src/components/layout/FocusModePanel.tsx index 53719eb..8d42d18 100644 --- a/src/components/layout/FocusModePanel.tsx +++ b/src/components/layout/FocusModePanel.tsx @@ -4,7 +4,7 @@ import type { EditorProps } from "$components/Editor"; import { StatusBar } from "$components/StatusBar"; import type { StatusBarProps } from "$components/StatusBar"; import { FocusIcon } from "$icons"; -import { useFocusModePanelState } from "$state/panel-selectors"; +import { useFocusModePanelState } from "$state/selectors"; import { useCallback } from "react"; type FocusModePanelProps = { diff --git a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx index 65153d3..f10e070 100644 --- a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx +++ b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx @@ -3,17 +3,17 @@ import { CollapsibleSection } from "$components/CollapsibleSection"; import { Dialog } from "$components/Dialog"; import { useViewportTier } from "$hooks/useViewportTier"; import { XIcon } from "$icons"; -import { globalCaptureSettingsAtom, layoutSettingsOpenAtom, setQuickCaptureEnabledAtom } from "$state/atoms/ui"; import { useCalmUiActions, useCalmUiSettings, + useGlobalCaptureSettingsState, useLayoutSettingsChromeState, useLayoutSettingsEditorState, useLayoutSettingsFocusState, + useLayoutSettingsUiState, useLayoutSettingsWriterToolsState, -} from "$state/panel-selectors"; +} from "$state/selectors"; import type { EditorFontFamily } from "$types"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { type ChangeEvent, useCallback, useMemo, useState } from "react"; import { CustomPatternControls } from "./CustomPatternControls"; import { DimmingModeRow } from "./DimmingModeRow"; @@ -21,11 +21,11 @@ import { FontFamilyRow, FontSizeRow } from "./FontRows"; import { ToggleRow } from "./ToggleRow"; const SettingsHeader = () => { - const setIsVisible = useSetAtom(layoutSettingsOpenAtom); + const { setOpen } = useLayoutSettingsUiState(); const handleClose = useCallback(() => { - setIsVisible(false); - }, [setIsVisible]); + setOpen(false); + }, [setOpen]); return (
@@ -128,8 +128,8 @@ function ChromeSettingsSection() { } function CalmUiSettingsSection() { - const { enabled, autoHide, focusMode } = useCalmUiSettings(); - const { toggleCalmUi, setCalmUiAutoHide, setCalmUiFocusMode } = useCalmUiActions(); + const { enabled, focusMode } = useCalmUiSettings(); + const { toggleCalmUi, setCalmUiFocusMode } = useCalmUiActions(); return (
@@ -147,12 +147,6 @@ function CalmUiSettingsSection() { description="Enter Focus mode when opening a document." isVisible={focusMode} onToggle={setCalmUiFocusMode} /> - -

Hold Ctrl+Shift+H to reveal hidden UI

)}
@@ -237,8 +231,8 @@ function WriterToolsSection() { } const QuickCaptureSection = () => { - const quickCaptureEnabled = useAtomValue(globalCaptureSettingsAtom).enabled; - const setQuickCaptureEnabled = useSetAtom(setQuickCaptureEnabledAtom); + const { settings, setQuickCaptureEnabled } = useGlobalCaptureSettingsState(); + const quickCaptureEnabled = settings.enabled; const handleQuickCaptureEnabledChange = useCallback((enabled: boolean) => { setQuickCaptureEnabled(enabled); @@ -277,12 +271,12 @@ const SettingsBody = () => ( ); export const LayoutSettingsPanel = () => { - const [isVisible, setIsVisible] = useAtom(layoutSettingsOpenAtom); + const { isOpen: isVisible, setOpen } = useLayoutSettingsUiState(); const { isCompact, viewportWidth } = useViewportTier(); const compactPanel = useMemo(() => isCompact || viewportWidth < 920, [isCompact, viewportWidth]); const handleClose = useCallback(() => { - setIsVisible(false); - }, [setIsVisible]); + setOpen(false); + }, [setOpen]); return ( (title : null); export function PdfExportDialog({ onExport }: PdfExportDialogProps) { - const [isOpen, setIsOpen] = useAtom(pdfExportDialogOpenAtom); - const options = useAtomValue(pdfExportOptionsAtom); + const { isOpen, setOpen: setIsOpen, options } = usePdfDialogUiState(); const { pdfExportError } = usePdfExportState(); const { resetPdfExport } = usePdfExportActions(); const { tabs, activeTabId } = useTabsState(); diff --git a/src/components/pdf/ExportDialog/ExportFooter.tsx b/src/components/pdf/ExportDialog/ExportFooter.tsx index a255d0a..2fad8ea 100644 --- a/src/components/pdf/ExportDialog/ExportFooter.tsx +++ b/src/components/pdf/ExportDialog/ExportFooter.tsx @@ -1,11 +1,9 @@ import { Button } from "$components/Button"; -import { pdfExportDialogOpenAtom } from "$state/atoms/ui"; -import { usePdfExportActions, usePdfExportState } from "$state/stores/app"; -import { useSetAtom } from "jotai"; +import { usePdfDialogUiState, usePdfExportActions, usePdfExportState } from "$state/selectors"; import { useCallback } from "react"; export const PdfExportDialogFooter = ({ handleExportClick }: { handleExportClick: () => void | Promise }) => { - const setIsOpen = useSetAtom(pdfExportDialogOpenAtom); + const { setOpen: setIsOpen } = usePdfDialogUiState(); const { resetPdfExport } = usePdfExportActions(); const { isExportingPdf } = usePdfExportState(); diff --git a/src/components/pdf/ExportDialog/ExportHeader.tsx b/src/components/pdf/ExportDialog/ExportHeader.tsx index 9bf5a80..f464c67 100644 --- a/src/components/pdf/ExportDialog/ExportHeader.tsx +++ b/src/components/pdf/ExportDialog/ExportHeader.tsx @@ -1,12 +1,10 @@ import { Button } from "$components/Button"; import { XIcon } from "$icons"; -import { pdfExportDialogOpenAtom } from "$state/atoms/ui"; -import { usePdfExportActions } from "$state/stores/app"; -import { useSetAtom } from "jotai"; +import { usePdfDialogUiState, usePdfExportActions } from "$state/selectors"; import { useCallback } from "react"; export const PdfExportDialogHeader = () => { - const setIsOpen = useSetAtom(pdfExportDialogOpenAtom); + const { setOpen: setIsOpen } = usePdfDialogUiState(); const { resetPdfExport } = usePdfExportActions(); const handleCancel = useCallback(() => { diff --git a/src/components/pdf/ExportDialog/ExportOptions.tsx b/src/components/pdf/ExportDialog/ExportOptions.tsx index 5dfe589..6f03548 100644 --- a/src/components/pdf/ExportDialog/ExportOptions.tsx +++ b/src/components/pdf/ExportDialog/ExportOptions.tsx @@ -1,19 +1,10 @@ import { ORIENTATIONS, PAGE_SIZES } from "$pdf/constants"; import { MarginSide, type PdfExportOptions } from "$pdf/types"; -import { - pdfExportOptionsAtom, - setPdfFontSizeAtom, - setPdfIncludeFooterAtom, - setPdfIncludeHeaderAtom, - setPdfMarginAtom, - setPdfOrientationAtom, - setPdfPageSizeAtom, -} from "$state/atoms/ui"; -import { useAtomValue, useSetAtom } from "jotai"; +import { usePdfDialogUiState } from "$state/selectors"; import { useCallback } from "react"; const PdfExportDialogPageSize = ({ options }: { options: PdfExportOptions }) => { - const setPageSize = useSetAtom(setPdfPageSizeAtom); + const { setPageSize } = usePdfDialogUiState(); const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { setPageSize(event.target.value as PdfExportOptions["pageSize"]); @@ -33,7 +24,7 @@ const PdfExportDialogPageSize = ({ options }: { options: PdfExportOptions }) => }; const PdfExportDialogOrientation = ({ options }: { options: PdfExportOptions }) => { - const setOrientation = useSetAtom(setPdfOrientationAtom); + const { setOrientation } = usePdfDialogUiState(); const handleOrientationChange = useCallback((event: React.ChangeEvent) => { setOrientation(event.target.value as PdfExportOptions["orientation"]); @@ -55,7 +46,7 @@ const PdfExportDialogOrientation = ({ options }: { options: PdfExportOptions }) }; const PdfExportDialogFontSize = ({ options }: { options: PdfExportOptions }) => { - const setFontSize = useSetAtom(setPdfFontSizeAtom); + const { setFontSize } = usePdfDialogUiState(); const handleFontSizeChange = useCallback((event: React.ChangeEvent) => { setFontSize(parseInt(event.target.value, 10)); @@ -77,10 +68,10 @@ const PdfExportDialogFontSize = ({ options }: { options: PdfExportOptions }) => }; const PdfMarginField = ({ side, value }: { side: MarginSide; value: number }) => { - const setMargin = useSetAtom(setPdfMarginAtom); + const { setMargin } = usePdfDialogUiState(); const onChange = useCallback((event: React.ChangeEvent) => { - setMargin({ side, value: parseInt(event.target.value || "0", 10) }); + setMargin(side, parseInt(event.target.value || "0", 10)); }, [setMargin, side]); return ( @@ -110,8 +101,7 @@ const PdfExportDialogMargins = ({ options }: { options: PdfExportOptions }) => ( ); const PdfExportDialogHeaderFooter = ({ options }: { options: PdfExportOptions }) => { - const setIncludeHeader = useSetAtom(setPdfIncludeHeaderAtom); - const setIncludeFooter = useSetAtom(setPdfIncludeFooterAtom); + const { setIncludeHeader, setIncludeFooter } = usePdfDialogUiState(); const onHeaderChange = useCallback((event: React.ChangeEvent) => { setIncludeHeader(event.target.checked); @@ -136,7 +126,7 @@ const PdfExportDialogHeaderFooter = ({ options }: { options: PdfExportOptions }) }; export const PdfExportDialogOptions = () => { - const options = useAtomValue(pdfExportOptionsAtom); + const { options } = usePdfDialogUiState(); return (
diff --git a/src/hooks/app/useDocumentSessionEffects.ts b/src/hooks/app/useDocumentSessionEffects.ts new file mode 100644 index 0000000..35b2dd5 --- /dev/null +++ b/src/hooks/app/useDocumentSessionEffects.ts @@ -0,0 +1,116 @@ +import { docExists, runCmd, sessionLastDocGet, sessionLastDocSet } from "$ports"; +import type { DocRef, LocationDescriptor, Tab } from "$types"; +import { useEffect, useRef } from "react"; + +type UseDocumentSessionEffectsArgs = { + isSidebarLoading: boolean; + locations: LocationDescriptor[]; + selectedLocationId?: number; + tabs: Tab[]; + activeTab: Tab | null; + documentsCount: number; + activeDocRef: DocRef | null; + openDoc: (docRef: DocRef) => void; + handleSelectDocument: (locationId: number, path: string) => void; + handleNewDocument: (locationId?: number) => void; +}; + +export function useDocumentSessionEffects( + { + isSidebarLoading, + locations, + selectedLocationId, + tabs, + activeTab, + documentsCount, + activeDocRef, + openDoc, + handleSelectDocument, + handleNewDocument, + }: UseDocumentSessionEffectsArgs, +): void { + const startupDocumentReadyRef = useRef(false); + const startupDocumentRestoredRef = useRef(false); + + useEffect(() => { + if (startupDocumentReadyRef.current) { + return; + } + + if (isSidebarLoading || locations.length === 0 || tabs.length > 0) { + return; + } + + startupDocumentReadyRef.current = true; + + const completeStartupRestore = () => { + startupDocumentRestoredRef.current = true; + }; + + const fallbackToBlankDraft = () => { + completeStartupRestore(); + 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]); + + useEffect(() => { + if (!activeDocRef) { + return; + } + + openDoc(activeDocRef); + }, [activeDocRef, openDoc]); + + useEffect(() => { + if (isSidebarLoading || !selectedLocationId || documentsCount > 0) { + return; + } + + if (activeTab?.docRef.location_id === selectedLocationId) { + return; + } + + const existingTab = tabs.find((tab) => tab.docRef.location_id === selectedLocationId); + if (existingTab) { + handleSelectDocument(existingTab.docRef.location_id, existingTab.docRef.rel_path); + return; + } + + handleNewDocument(selectedLocationId); + }, [activeTab, documentsCount, handleNewDocument, handleSelectDocument, isSidebarLoading, selectedLocationId, tabs]); + + useEffect(() => { + if (!startupDocumentRestoredRef.current) { + return; + } + + void runCmd(sessionLastDocSet(activeDocRef, () => {}, () => {})); + }, [activeDocRef]); +} diff --git a/src/hooks/app/useEditorPreviewEffects.ts b/src/hooks/app/useEditorPreviewEffects.ts new file mode 100644 index 0000000..795db46 --- /dev/null +++ b/src/hooks/app/useEditorPreviewEffects.ts @@ -0,0 +1,53 @@ +import type { SidebarRefreshReason } from "$state/types"; +import type { DocRef, SaveStatus, Tab } from "$types"; +import { useEffect, useRef } from "react"; + +type UseEditorPreviewEffectsArgs = { + activeTab: Tab | null; + text: string; + saveStatus: SaveStatus; + markActiveTabModified: (isModified: boolean) => void; + setPreviewDoc: (docRef: DocRef | null) => void; + renderPreview: (docRef: DocRef, text: string) => void; + handleRefreshSidebar: (locationId?: number, options?: { source?: SidebarRefreshReason }) => void; +}; + +export function useEditorPreviewEffects( + { activeTab, text, saveStatus, markActiveTabModified, setPreviewDoc, renderPreview, handleRefreshSidebar }: + UseEditorPreviewEffectsArgs, +): void { + const previousSaveStatusRef = useRef(saveStatus); + + useEffect(() => { + markActiveTabModified(saveStatus === "Dirty"); + }, [saveStatus, markActiveTabModified]); + + useEffect(() => { + const previousSaveStatus = previousSaveStatusRef.current; + if (previousSaveStatus === "Saving" && saveStatus === "Saved") { + handleRefreshSidebar(activeTab?.docRef.location_id, { source: "save" }); + } + + previousSaveStatusRef.current = saveStatus; + }, [activeTab, saveStatus, handleRefreshSidebar]); + + useEffect(() => { + if (activeTab) { + setPreviewDoc(activeTab.docRef); + } else { + setPreviewDoc(null); + } + }, [activeTab, setPreviewDoc]); + + useEffect(() => { + if (!activeTab) { + return; + } + + const timeoutId = setTimeout(() => { + renderPreview(activeTab.docRef, text); + }, 300); + + return () => clearTimeout(timeoutId); + }, [activeTab, text, renderPreview]); +} diff --git a/src/hooks/app/useSettingsSync.ts b/src/hooks/app/useSettingsSync.ts new file mode 100644 index 0000000..bb70863 --- /dev/null +++ b/src/hooks/app/useSettingsSync.ts @@ -0,0 +1,100 @@ +import { logger } from "$logger"; +import { globalCaptureGet, runCmd, styleCheckGet, styleCheckSet, uiLayoutGet, uiLayoutSet } from "$ports"; +import { stateToLayoutSettings, uiSettingsToCalmUI, uiSettingsToFocusMode } from "$state/helpers"; +import { + useEditorPresentationStateRaw, + useLayoutChromeState, + useViewModeState, + useWriterToolsState, +} from "$state/selectors"; +import { useAppStore } from "$state/stores/app"; +import { useEffect, useState } from "react"; + +export function useSettingsSync(): void { + const layoutChrome = useLayoutChromeState(); + const editorPresentation = useEditorPresentationStateRaw(); + const { focusModeSettings } = useViewModeState(); + const { styleCheckSettings } = useWriterToolsState(); + const [layoutSettingsHydrated, setLayoutSettingsHydrated] = useState(false); + + useEffect(() => { + let isCancelled = false; + + void runCmd(uiLayoutGet((settings) => { + if (isCancelled) { + return; + } + + const state = useAppStore.getState(); + state.setSidebarCollapsed(settings.sidebar_collapsed); + state.setTopBarsCollapsed(settings.top_bars_collapsed); + state.setStatusBarCollapsed(settings.status_bar_collapsed); + state.setLineNumbersVisible(settings.line_numbers_visible); + state.setTextWrappingEnabled(settings.text_wrapping_enabled); + state.setSyntaxHighlightingEnabled(settings.syntax_highlighting_enabled); + state.setEditorFontSize(settings.editor_font_size); + state.setEditorFontFamily(settings.editor_font_family); + state.setCalmUiSettings(uiSettingsToCalmUI(settings)); + state.setFocusModeSettings(uiSettingsToFocusMode(settings)); + setLayoutSettingsHydrated(true); + }, () => { + if (!isCancelled) { + setLayoutSettingsHydrated(true); + } + })); + + void runCmd(styleCheckGet((settings) => { + if (isCancelled) { + return; + } + + useAppStore.getState().setStyleCheckSettings({ + enabled: settings.enabled, + categories: settings.categories, + customPatterns: settings.custom_patterns, + }); + }, () => {})); + + void runCmd(globalCaptureGet((settings) => { + if (isCancelled) { + return; + } + + useAppStore.getState().setGlobalCaptureSettings(settings); + }, (error) => { + logger.error("Failed to load global capture settings", error); + })); + + return () => { + isCancelled = true; + }; + }, []); + + useEffect(() => { + if (!layoutSettingsHydrated) { + return; + } + + void runCmd( + uiLayoutSet(stateToLayoutSettings(layoutChrome, editorPresentation, focusModeSettings), () => {}, () => {}), + ); + }, [layoutSettingsHydrated, layoutChrome, editorPresentation, focusModeSettings]); + + useEffect(() => { + if (!layoutSettingsHydrated) { + return; + } + + void runCmd( + styleCheckSet( + { + categories: styleCheckSettings.categories, + enabled: styleCheckSettings.enabled, + custom_patterns: styleCheckSettings.customPatterns, + }, + () => {}, + () => {}, + ), + ); + }, [layoutSettingsHydrated, styleCheckSettings]); +} diff --git a/src/hooks/controllers/useAppController.ts b/src/hooks/controllers/useAppController.ts new file mode 100644 index 0000000..df41584 --- /dev/null +++ b/src/hooks/controllers/useAppController.ts @@ -0,0 +1,255 @@ +import type { WorkspaceEditorProps, WorkspacePanelProps } from "$components/layout/WorkspacePanel"; +import { StatusBarProps } from "$components/StatusBar"; +import { useDocumentSessionEffects } from "$hooks/app/useDocumentSessionEffects"; +import { useEditorPreviewEffects } from "$hooks/app/useEditorPreviewEffects"; +import { useSettingsSync } from "$hooks/app/useSettingsSync"; +import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; +import { useDocumentActions } from "$hooks/useDocumentActions"; +import { useEditor } from "$hooks/useEditor"; +import { useEditorBridge } from "$hooks/useEditorBridge"; +import { useLayoutHotkeys } from "$hooks/useLayoutHotkeys"; +import { usePdfExport, usePdfExportUI } from "$hooks/usePdfExport"; +import { usePreview } from "$hooks/usePreview"; +import { useWorkspaceSync } from "$hooks/useWorkspaceSync"; +import type { PdfExportOptions } from "$pdf/types"; +import { + useEditorPresentationStateRaw, + useLayoutChromeActions, + useLayoutChromeState, + useViewModeState, +} from "$state/selectors"; +import type { AppTheme, DocRef } from "$types"; +import { useCallback, useMemo } from "react"; + +export type FocusModePanelProps = { editor: WorkspaceEditorProps; statusBar: StatusBarProps }; + +export type AppController = { + theme: AppTheme; + isFocusMode: boolean; + isSidebarCollapsed: boolean; + showToggleControls: boolean; + workspacePanelProps: WorkspacePanelProps; + focusModePanelProps: FocusModePanelProps; + handleShowSidebar: () => void; + handleExportPdf: (options: PdfExportOptions) => Promise; +}; + +function isSameDocRef(left: DocRef | null | undefined, right: DocRef | null | undefined): boolean { + if (!left || !right) { + return false; + } + + return left.location_id === right.location_id && left.rel_path === right.rel_path; +} + +export function useAppController(): AppController { + const { model: editorModel, dispatch: editorDispatch, openDoc } = useEditor(); + const { model: previewModel, render: renderPreview, syncLine: syncPreviewLine, setDoc: setPreviewDoc } = usePreview(); + const exportPdf = usePdfExport(); + + useWorkspaceSync(); + useLayoutHotkeys(); + + const layoutChrome = useLayoutChromeState(); + const { setSidebarCollapsed } = useLayoutChromeActions(); + const editorPresentation = useEditorPresentationStateRaw(); + const { isFocusMode } = useViewModeState(); + const { + locations, + documents, + selectedLocationId, + isSidebarLoading, + tabs, + activeTab, + markActiveTabModified, + handleSelectDocument, + handleCreateDraftTab, + handleCreateNewDocument, + handleRefreshSidebar, + } = useWorkspaceController(); + + const { handleSave, handleNewDocument } = useDocumentActions({ + editorDocRef: editorModel.docRef, + selectedLocationId, + documents, + tabs, + dispatchEditor: editorDispatch, + createDraftTab: handleCreateDraftTab, + createNewDocument: handleCreateNewDocument, + }); + const { handleEditorChange, handleCursorMove, handleSelectionChange } = useEditorBridge({ + dispatchEditor: editorDispatch, + syncPreviewLine, + }); + const { handleOpenPdfExport, handleExportPdf } = usePdfExportUI({ + activeTab, + text: editorModel.text, + editorFontFamily: editorPresentation.editorFontFamily, + exportPdf, + }); + + const hasOpenDocument = useMemo(() => isSameDocRef(activeTab?.docRef, editorModel.docRef), [ + activeTab, + editorModel.docRef, + ]); + const hasLocations = useMemo(() => locations.length > 0, [locations.length]); + + const cursorPosition = useMemo( + () => ({ cursorLine: editorModel.cursorLine, cursorColumn: editorModel.cursorColumn }), + [editorModel.cursorLine, editorModel.cursorColumn], + ); + + const { wordCount, charCount, selectionCount } = useMemo(() => { + const { text } = editorModel; + const trimmedText = text.trim(); + + return { + wordCount: trimmedText ? trimmedText.split(/\s+/).length : 0, + charCount: text.length, + selectionCount: editorModel.selectionFrom !== null && editorModel.selectionTo !== null + ? editorModel.selectionTo - editorModel.selectionFrom + : undefined, + }; + }, [editorModel.selectionFrom, editorModel.selectionTo, editorModel.text]); + + const editorStats = useMemo(() => ({ ...cursorPosition, wordCount, charCount, selectionCount }), [ + cursorPosition, + wordCount, + charCount, + selectionCount, + ]); + + const activeDocMeta = useMemo(() => { + if (!activeTab) { + return null; + } + + const activeDoc = documents.find((doc) => + doc.location_id === activeTab.docRef.location_id && doc.rel_path === activeTab.docRef.rel_path + ); + return activeDoc ?? null; + }, [activeTab, documents]); + + const handleShowSidebar = useCallback(() => setSidebarCollapsed(false), [setSidebarCollapsed]); + + const activeDocRef = activeTab?.docRef ?? null; + + useDocumentSessionEffects({ + isSidebarLoading, + locations, + selectedLocationId, + tabs, + activeTab, + documentsCount: documents.length, + activeDocRef, + openDoc, + handleSelectDocument, + handleNewDocument, + }); + + useEditorPreviewEffects({ + activeTab, + text: editorModel.text, + saveStatus: editorModel.saveStatus, + markActiveTabModified, + setPreviewDoc, + renderPreview, + handleRefreshSidebar, + }); + + useSettingsSync(); + + const calmUiEffectiveVisibility = useMemo(() => { + return { sidebar: true, statusBar: true, tabBar: true }; + }, []); + + const toolbarProps = useMemo( + () => ({ + saveStatus: editorModel.saveStatus, + hasActiveDocument: hasOpenDocument, + onSave: handleSave, + onNewDocument: handleNewDocument, + isNewDocumentDisabled: !hasLocations, + onExportPdf: handleOpenPdfExport, + isPdfExportDisabled: !activeTab, + onRefresh: handleRefreshSidebar, + }), + [ + editorModel.saveStatus, + activeTab, + handleOpenPdfExport, + handleNewDocument, + handleRefreshSidebar, + hasLocations, + handleSave, + hasOpenDocument, + ], + ); + + const editorProps = useMemo( + () => ({ + initialText: editorModel.text, + onChange: handleEditorChange, + onSave: handleSave, + onCursorMove: handleCursorMove, + onSelectionChange: handleSelectionChange, + }), + [editorModel.text, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange], + ); + + const statusBarProps = useMemo(() => ({ docMeta: activeDocMeta, stats: editorStats }), [activeDocMeta, editorStats]); + + const previewProps = useMemo( + () => ({ + renderResult: previewModel.renderResult, + theme: editorPresentation.theme, + editorLine: editorModel.cursorLine, + onScrollToLine: syncPreviewLine, + }), + [previewModel.renderResult, editorPresentation.theme, editorModel.cursorLine, syncPreviewLine], + ); + + const focusModePanelProps = useMemo( + () => ({ + editor: { + initialText: editorModel.text, + onChange: handleEditorChange, + onSave: handleSave, + onCursorMove: handleCursorMove, + onSelectionChange: handleSelectionChange, + }, + statusBar: { docMeta: activeDocMeta, stats: editorStats }, + }), + [ + activeDocMeta, + editorStats, + editorModel.text, + handleEditorChange, + handleSave, + handleCursorMove, + handleSelectionChange, + ], + ); + + const workspacePanelProps = useMemo( + () => ({ + toolbar: toolbarProps, + editor: editorProps, + preview: previewProps, + statusBar: statusBarProps, + calmUiVisibility: calmUiEffectiveVisibility, + }), + [toolbarProps, editorProps, previewProps, statusBarProps, calmUiEffectiveVisibility], + ); + + return { + theme: editorPresentation.theme, + isFocusMode, + isSidebarCollapsed: layoutChrome.sidebarCollapsed, + showToggleControls: layoutChrome.sidebarCollapsed, + workspacePanelProps, + focusModePanelProps, + handleShowSidebar, + handleExportPdf, + }; +} diff --git a/src/hooks/useSearchController.ts b/src/hooks/controllers/useSearchController.ts similarity index 82% rename from src/hooks/useSearchController.ts rename to src/hooks/controllers/useSearchController.ts index e649e9e..f1f3ef2 100644 --- a/src/hooks/useSearchController.ts +++ b/src/hooks/controllers/useSearchController.ts @@ -1,16 +1,12 @@ import { logger } from "$logger"; import { runCmd, searchDocuments, type SearchFiltersPayload } from "$ports"; -import { isSearchingAtom, searchFiltersAtom, searchQueryAtom, searchResultsAtom } from "$state/atoms/search"; -import { useLayoutChromeActions } from "$state/stores/app"; +import { useLayoutChromeActions, useSearchActions, useSearchState } from "$state/selectors"; import type { SearchHit } from "$types"; -import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; export function useSearchController(onSelectDocument: (locationId: number, path: string) => void) { - const [searchQuery, setSearchQuery] = useAtom(searchQueryAtom); - const [searchResults, setSearchResults] = useAtom(searchResultsAtom); - const [isSearching, setIsSearching] = useAtom(isSearchingAtom); - const [filters, setFilters] = useAtom(searchFiltersAtom); + const { searchQuery, searchResults, isSearching, filters } = useSearchState(); + const { setSearchQuery, setSearchResults, setIsSearching, setFilters } = useSearchActions(); const { setShowSearch } = useLayoutChromeActions(); const requestIdRef = useRef(0); diff --git a/src/hooks/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts similarity index 53% rename from src/hooks/useWorkspaceController.ts rename to src/hooks/controllers/useWorkspaceController.ts index 5a246b3..fc95b07 100644 --- a/src/hooks/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -1,23 +1,54 @@ import { logger } from "$logger"; -import { locationAddViaDialog, locationRemove, runCmd } from "$ports"; +import { docList, locationAddViaDialog, locationRemove, runCmd } from "$ports"; import { - useAppStore, useTabsActions, useTabsState, + useWorkspaceDocumentsActions, useWorkspaceDocumentsState, useWorkspaceLocationsActions, useWorkspaceLocationsState, -} from "$state/stores/app"; -import type { DocRef, Tab } from "$types"; +} from "$state/selectors"; +import { useAppStore } from "$state/stores/app"; +import type { SidebarRefreshReason } from "$state/types"; +import type { DocMeta, DocRef, Tab } from "$types"; import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; import { useCallback, useMemo } from "react"; +const TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS = 120; + +type RefreshSidebarOptions = { source?: SidebarRefreshReason; attempt?: number }; + +function areDocumentsEqual(left: DocMeta[], right: DocMeta[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + const a = left[index]; + const b = right[index]; + if ( + a.location_id !== b.location_id + || a.rel_path !== b.rel_path + || a.title !== b.title + || a.updated_at !== b.updated_at + || a.word_count !== b.word_count + ) { + return false; + } + } + + return true; +} + export function useWorkspaceController() { const { locations, selectedLocationId, isLoadingLocations, sidebarFilter } = useWorkspaceLocationsState(); - const { selectedDocPath, documents, isLoadingDocuments } = useWorkspaceDocumentsState(); + const { selectedDocPath, documents, isLoadingDocuments, refreshingLocationId, sidebarRefreshReason } = + useWorkspaceDocumentsState(); + const { setSidebarRefreshState } = useWorkspaceDocumentsActions(); const { setSidebarFilter, setSelectedLocation, addLocation, removeLocation } = useWorkspaceLocationsActions(); const { tabs, activeTabId } = useTabsState(); const { openDocumentTab, selectTab, closeTab, reorderTabs, markActiveTabModified } = useTabsActions(); + const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [activeTabId, tabs]); const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), @@ -87,6 +118,59 @@ export function useWorkspaceController() { return docRef; }, [openDocumentTab]); + const handleRefreshSidebar = useCallback((locationId?: number, options: RefreshSidebarOptions = {}) => { + const source = options.source ?? "manual"; + const attempt = options.attempt ?? 0; + const state = useAppStore.getState(); + const targetLocationId = locationId ?? state.selectedLocationId ?? state.locations[0]?.id; + + if (!targetLocationId || state.selectedLocationId !== targetLocationId) { + return; + } + + setSidebarRefreshState(targetLocationId, source); + + runCmd(docList(targetLocationId, (nextDocuments) => { + const latestState = useAppStore.getState(); + if (latestState.selectedLocationId !== targetLocationId) { + if (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + return; + } + + // Atomic save operations can emit a brief empty listing between fs events. + // Retry once to avoid flashing an empty list in the sidebar. + if (nextDocuments.length === 0 && latestState.documents.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 (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + }, (error) => { + if (attempt === 0) { + setTimeout(() => { + handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); + }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); + return; + } + + logger.error("Failed to refresh sidebar documents", { locationId: targetLocationId, error }); + const latestState = useAppStore.getState(); + if (latestState.refreshingLocationId === targetLocationId) { + latestState.setSidebarRefreshState(undefined, null); + } + })); + }, [setSidebarRefreshState]); + return useMemo( () => ({ locations, @@ -96,8 +180,11 @@ export function useWorkspaceController() { locationDocuments, sidebarFilter, isSidebarLoading: isLoadingLocations || isLoadingDocuments, + refreshingLocationId, + sidebarRefreshReason, tabs, activeTabId, + activeTab, setSidebarFilter, markActiveTabModified, handleAddLocation, @@ -109,6 +196,7 @@ export function useWorkspaceController() { handleReorderTabs, handleCreateDraftTab, handleCreateNewDocument, + handleRefreshSidebar, }), [ locations, @@ -119,8 +207,11 @@ export function useWorkspaceController() { sidebarFilter, isLoadingLocations, isLoadingDocuments, + refreshingLocationId, + sidebarRefreshReason, tabs, activeTabId, + activeTab, setSidebarFilter, markActiveTabModified, handleAddLocation, @@ -132,6 +223,7 @@ export function useWorkspaceController() { handleReorderTabs, handleCreateDraftTab, handleCreateNewDocument, + handleRefreshSidebar, ], ); } diff --git a/src/hooks/useBackendEvents.ts b/src/hooks/useBackendEvents.ts index a2d1e19..9310911 100644 --- a/src/hooks/useBackendEvents.ts +++ b/src/hooks/useBackendEvents.ts @@ -3,7 +3,7 @@ import type { BackendEvent } from "$ports"; import type { LocationId } from "$types"; import type { Event as TauriEvent, UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; export type BackendEventState = { events: BackendEvent[]; @@ -11,7 +11,15 @@ export type BackendEventState = { conflicts: Array<{ location_id: LocationId; rel_path: string; conflict_filename: string }>; }; -export function useBackendEvents(): BackendEventState { +export type UseBackendEventsOptions = { + onLocationMissing?: (locationId: LocationId, path: string) => void; + onLocationChanged?: (locationId: LocationId, oldPath: string, newPath: string) => void; + onReconciliationComplete?: (checked: number, missing: LocationId[]) => void; +}; + +export function useBackendEvents(options: UseBackendEventsOptions = {}): BackendEventState { + const optionsRef = useRef(options); + optionsRef.current = options; const [events, setEvents] = useState([]); const [missingLocations, setMissingLocations] = useState>([]); const [conflicts, setConflicts] = useState< @@ -31,6 +39,7 @@ export function useBackendEvents(): BackendEventState { switch (payload.type) { case "LocationMissing": { setMissingLocations((prev) => [...prev, { location_id: payload.location_id, path: payload.path }]); + optionsRef.current.onLocationMissing?.(payload.location_id, payload.path); logger.warn("Location missing", { locationId: payload.location_id, path: payload.path }); break; } @@ -50,6 +59,7 @@ export function useBackendEvents(): BackendEventState { break; } case "ReconciliationComplete": { + optionsRef.current.onReconciliationComplete?.(payload.checked, payload.missing); logger.info("Reconciliation complete", { checked: payload.checked, missingCount: payload.missing.length, @@ -57,6 +67,7 @@ export function useBackendEvents(): BackendEventState { break; } case "LocationChanged": { + optionsRef.current.onLocationChanged?.(payload.location_id, payload.old_path, payload.new_path); logger.info("Location changed", { locationId: payload.location_id, oldPath: payload.old_path, diff --git a/src/hooks/useCmdLoop.ts b/src/hooks/useCmdLoop.ts new file mode 100644 index 0000000..f8c1589 --- /dev/null +++ b/src/hooks/useCmdLoop.ts @@ -0,0 +1,45 @@ +import type { Cmd } from "$ports"; +import { runCmd } from "$ports"; +import { useCallback, useState } from "react"; + +export type UpdateFn = (model: Model, msg: Msg) => [Model, Cmd]; + +export function useCmdLoop( + initialModel: Model, + update: UpdateFn, + isMsg: (value: unknown) => value is Msg, +): { model: Model; dispatch: (msg: Msg) => void } { + const [model, setModel] = useState(initialModel); + + const dispatch = useCallback((msg: Msg) => { + setModel((prevModel) => { + const [nextModel, cmd] = update(prevModel, msg); + + if (cmd.type === "Invoke") { + const wrappedCmd: Cmd = { + ...cmd, + onOk: (value) => { + const nextMsg = cmd.onOk(value); + if (isMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + onErr: (error) => { + const nextMsg = cmd.onErr(error); + if (isMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + }; + + void runCmd(wrappedCmd); + } else if (cmd.type !== "None") { + void runCmd(cmd); + } + + return nextModel; + }); + }, [isMsg, update]); + + return { model, dispatch }; +} diff --git a/src/hooks/useDocumentActions.ts b/src/hooks/useDocumentActions.ts new file mode 100644 index 0000000..d96fdf4 --- /dev/null +++ b/src/hooks/useDocumentActions.ts @@ -0,0 +1,49 @@ +import type { EditorMsg } from "$hooks/useEditor"; +import { logger } from "$logger"; +import type { DocMeta, DocRef, Tab } from "$types"; +import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; +import { useCallback } from "react"; + +type UseDocumentActionsArgs = { + editorDocRef: DocRef | null; + selectedLocationId?: number; + documents: DocMeta[]; + tabs: Tab[]; + dispatchEditor: (msg: EditorMsg) => void; + createDraftTab: (docRef: DocRef, title: string) => void; + createNewDocument: (locationId?: number) => DocRef | null; +}; + +export function useDocumentActions( + { editorDocRef, selectedLocationId, documents, tabs, dispatchEditor, createDraftTab, createNewDocument }: + UseDocumentActionsArgs, +) { + const handleSave = useCallback(() => { + if (!editorDocRef && !selectedLocationId) { + logger.warn("Cannot save draft without a selected location."); + return; + } + + if (!editorDocRef && selectedLocationId) { + const relPath = buildDraftRelPath(selectedLocationId, documents, tabs); + const draftRef: DocRef = { location_id: selectedLocationId, rel_path: relPath }; + + createDraftTab(draftRef, getDraftTitle(relPath)); + dispatchEditor({ type: "DraftDocInitialized", docRef: draftRef }); + } + + dispatchEditor({ type: "SaveRequested" }); + }, [createDraftTab, dispatchEditor, documents, editorDocRef, selectedLocationId, tabs]); + + const handleNewDocument = useCallback((locationId?: number) => { + const draftRef = createNewDocument(locationId); + if (!draftRef) { + logger.warn("Cannot create a new document without a selected location."); + return; + } + + dispatchEditor({ type: "NewDraftCreated", docRef: draftRef }); + }, [createNewDocument, dispatchEditor]); + + return { handleSave, handleNewDocument }; +} diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts index 3b2b9c5..70b66b4 100644 --- a/src/hooks/useEditor.ts +++ b/src/hooks/useEditor.ts @@ -1,7 +1,8 @@ import type { Cmd, SaveResult } from "$ports"; -import { docOpen, docSave, none, runCmd } from "$ports"; +import { docOpen, docSave, none } from "$ports"; import type { AppError, DocContent, DocRef, SaveStatus } from "$types"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { useCmdLoop } from "./useCmdLoop"; export type EditorModel = { docRef: DocRef | null; @@ -160,36 +161,7 @@ export interface UseEditorReturn { } export function useEditor(): UseEditorReturn { - const [model, setModel] = useState(initialEditorModel); - - const dispatch = useCallback((msg: EditorMsg) => { - setModel((prevModel) => { - const [newModel, cmd] = updateEditor(prevModel, msg); - if (cmd.type !== "None") { - if (cmd.type === "Invoke") { - const wrappedCmd: Cmd = { - ...cmd, - onOk: (value) => { - const nextMsg = cmd.onOk(value); - if (isEditorMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - onErr: (error) => { - const nextMsg = cmd.onErr(error); - if (isEditorMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - }; - runCmd(wrappedCmd); - } else { - runCmd(cmd); - } - } - return newModel; - }); - }, []); + const { model, dispatch } = useCmdLoop(initialEditorModel, updateEditor, isEditorMsg); const openDoc = useCallback((docRef: DocRef) => { dispatch({ type: "OpenDocRequested", docRef }); diff --git a/src/hooks/useEditorBridge.ts b/src/hooks/useEditorBridge.ts new file mode 100644 index 0000000..d81b3ec --- /dev/null +++ b/src/hooks/useEditorBridge.ts @@ -0,0 +1,21 @@ +import type { EditorMsg } from "$hooks/useEditor"; +import { useCallback } from "react"; + +type UseEditorBridgeArgs = { dispatchEditor: (msg: EditorMsg) => void; syncPreviewLine: (line: number) => void }; + +export function useEditorBridge({ dispatchEditor, syncPreviewLine }: UseEditorBridgeArgs) { + const handleEditorChange = useCallback((text: string) => { + dispatchEditor({ type: "EditorChanged", text }); + }, [dispatchEditor]); + + const handleCursorMove = useCallback((line: number, column: number) => { + dispatchEditor({ type: "CursorMoved", line, column }); + syncPreviewLine(line); + }, [dispatchEditor, syncPreviewLine]); + + const handleSelectionChange = useCallback((from: number, to: number | null) => { + dispatchEditor({ type: "SelectionChanged", from, to }); + }, [dispatchEditor]); + + return { handleEditorChange, handleCursorMove, handleSelectionChange }; +} diff --git a/src/hooks/useLayoutHotkeys.ts b/src/hooks/useLayoutHotkeys.ts index 294d4ec..adfa07c 100644 --- a/src/hooks/useLayoutHotkeys.ts +++ b/src/hooks/useLayoutHotkeys.ts @@ -1,5 +1,5 @@ +import { useLayoutChromeActions, useViewModeActions } from "$state/selectors"; import { useEffect } from "react"; -import { useLayoutChromeActions, useViewModeActions } from "../state/stores/app"; export function useLayoutHotkeys(): void { const { diff --git a/src/hooks/usePdfExport.tsx b/src/hooks/usePdfExport.tsx index 5994764..9591078 100644 --- a/src/hooks/usePdfExport.tsx +++ b/src/hooks/usePdfExport.tsx @@ -3,8 +3,9 @@ import { logger } from "$logger"; import { serializeError } from "$pdf/errors"; import { describePdfFont, ensurePdfFontRegistered } from "$pdf/fonts"; import type { FontStrategy, PdfExportOptions, PdfRenderResult } from "$pdf/types"; -import { usePdfExportActions } from "$state/stores/app"; -import type { EditorFontFamily } from "$types"; +import { renderMarkdownForPdf, runCmd } from "$ports"; +import { usePdfDialogUiState, usePdfExportActions } from "$state/selectors"; +import type { EditorFontFamily, Tab } from "$types"; import { pdf } from "@react-pdf/renderer"; import { save } from "@tauri-apps/plugin-dialog"; import { writeFile } from "@tauri-apps/plugin-fs"; @@ -120,3 +121,50 @@ export function usePdfExport(): ExportPdfFn { return exportPdf; } + +type UsePdfExportUIArgs = { + activeTab: Tab | null; + text: string; + editorFontFamily: EditorFontFamily; + exportPdf: ExportPdfFn; +}; + +export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf }: UsePdfExportUIArgs) { + const { setOpen: setPdfExportDialogOpen } = usePdfDialogUiState(); + const { resetPdfExport } = usePdfExportActions(); + + const handleOpenPdfExport = useCallback(() => { + if (!activeTab) { + logger.warn("Cannot export PDF without an active document."); + return; + } + + resetPdfExport(); + setPdfExportDialogOpen(true); + }, [activeTab, resetPdfExport, setPdfExportDialogOpen]); + + const handleExportPdf = useCallback(async (options: PdfExportOptions) => { + if (!activeTab) { + logger.warn("Cannot export PDF without an active document."); + return; + } + + const docRef = activeTab.docRef; + + try { + const renderResult = await new Promise((resolve, reject) => { + void runCmd(renderMarkdownForPdf(docRef.location_id, docRef.rel_path, text, void 0, resolve, reject)); + }); + + const didExport = await exportPdf(renderResult, options, editorFontFamily); + if (didExport) { + setPdfExportDialogOpen(false); + resetPdfExport(); + } + } catch (error) { + logger.error("Failed to export PDF", { error: error instanceof Error ? error.message : String(error) }); + } + }, [activeTab, editorFontFamily, exportPdf, resetPdfExport, setPdfExportDialogOpen, text]); + + return { handleOpenPdfExport, handleExportPdf }; +} diff --git a/src/hooks/usePreview.ts b/src/hooks/usePreview.ts index 87ab5b3..902ce0f 100644 --- a/src/hooks/usePreview.ts +++ b/src/hooks/usePreview.ts @@ -1,7 +1,8 @@ import type { Cmd } from "$ports"; -import { none, renderMarkdown, runCmd } from "$ports"; +import { none, renderMarkdown } from "$ports"; import type { AppError, DocRef, RenderResult } from "$types"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { useCmdLoop } from "./useCmdLoop"; export type PreviewModel = { docRef: DocRef | null; @@ -82,36 +83,7 @@ export function updatePreview(model: PreviewModel, msg: PreviewMsg): [PreviewMod } export function usePreview(): UsePreviewReturn { - const [model, setModel] = useState(initialPreviewModel); - - const dispatch = useCallback((msg: PreviewMsg) => { - setModel((prevModel) => { - const [newModel, cmd] = updatePreview(prevModel, msg); - if (cmd.type !== "None") { - if (cmd.type === "Invoke") { - const wrappedCmd: Cmd = { - ...cmd, - onOk: (value) => { - const nextMsg = cmd.onOk(value); - if (isPreviewMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - onErr: (error) => { - const nextMsg = cmd.onErr(error); - if (isPreviewMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - }; - runCmd(wrappedCmd); - } else { - runCmd(cmd); - } - } - return newModel; - }); - }, []); + const { model, dispatch } = useCmdLoop(initialPreviewModel, updatePreview, isPreviewMsg); const render = useCallback((docRef: DocRef, text: string) => { dispatch({ type: "RenderRequested", docRef, text }); diff --git a/src/hooks/useWorkspaceSync.ts b/src/hooks/useWorkspaceSync.ts index 840d876..a1fbe10 100644 --- a/src/hooks/useWorkspaceSync.ts +++ b/src/hooks/useWorkspaceSync.ts @@ -1,11 +1,11 @@ import { logger } from "$logger"; import { backendEvents, docList, locationList, runCmd, startWatch, stopWatch, SubscriptionManager } from "$ports"; -import { useCallback, useEffect, useRef } from "react"; import { useWorkspaceDocumentsActions, useWorkspaceLocationsActions, useWorkspaceLocationsState, -} from "../state/stores/app"; +} from "$state/selectors"; +import { useCallback, useEffect, useRef } from "react"; export function useWorkspaceSync(): void { const { selectedLocationId } = useWorkspaceLocationsState(); diff --git a/src/ports/commands.ts b/src/ports/commands.ts new file mode 100644 index 0000000..ee41e21 --- /dev/null +++ b/src/ports/commands.ts @@ -0,0 +1,173 @@ +import type { PdfRenderResult } from "$pdf/types"; +import type { + CaptureDocRef, + CaptureSubmitInput, + CaptureSubmitResult, + DocContent, + DocMeta, + DocRef, + GlobalCaptureSettings, + LocationDescriptor, + LocationId, + MarkdownProfile, + RenderResult, + SearchHit, +} from "$types"; +import { invokeCmd } from "./invoke"; +import type { + BackendCaptureDocRef, + BackendCaptureSubmitInput, + BackendGlobalCaptureSettings, + Cmd, + DocListParams, + DocOpenParams, + DocSaveParams, + GlobalCaptureGetParams, + GlobalCapturePauseParams, + GlobalCaptureSetParams, + GlobalCaptureSubmitParams, + GlobalCaptureValidateShortcutParams, + LocParams, + PersistedStyleCheckSettings, + RenderMarkdownForPdfParams, + RenderMarkdownParams, + SaveResult, + SearchFiltersPayload, + SearchParams, + SessionLastDocSetParams, + StyleCheckSetParams, + UiLayoutSetParams, + UiLayoutSettings, +} from "./types"; + +function toBackendCaptureDocRef(value: CaptureDocRef | null): BackendCaptureDocRef | null { + if (!value) { + return null; + } + + return { location_id: value.locationId, rel_path: value.relPath }; +} + +function toBackendGlobalCaptureSettings(settings: GlobalCaptureSettings): BackendGlobalCaptureSettings { + return { + enabled: settings.enabled, + shortcut: settings.shortcut, + paused: settings.paused, + default_mode: settings.defaultMode, + target_location_id: settings.targetLocationId, + inbox_relative_dir: settings.inboxRelativeDir, + append_target: toBackendCaptureDocRef(settings.appendTarget), + close_after_save: settings.closeAfterSave, + show_tray_icon: settings.showTrayIcon, + last_capture_target: settings.lastCaptureTarget, + }; +} + +function toBackendCaptureSubmitInput(input: CaptureSubmitInput): BackendCaptureSubmitInput { + return { + mode: input.mode, + text: input.text, + destination: input.destination + ? { location_id: input.destination.locationId, rel_path: input.destination.relPath } + : void 0, + open_main_after_save: input.openMainAfterSave, + }; +} + +export function locationAddViaDialog(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("location_add_via_dialog", {}, onOk, onErr); +} + +export function locationList(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("location_list", {}, onOk, onErr); +} + +export function locationRemove(...[locationId, onOk, onErr]: DocListParams): Cmd { + return invokeCmd("location_remove", { locationId }, onOk, onErr); +} + +export function locationValidate(...[onOk, onErr]: LocParams>): Cmd { + return invokeCmd>("location_validate", {}, onOk, onErr); +} + +export function docList(...[locationId, onOk, onErr]: DocListParams): Cmd { + return invokeCmd("doc_list", { locationId }, onOk, onErr); +} + +export function docOpen(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { + return invokeCmd("doc_open", { locationId, relPath }, onOk, onErr); +} + +export function docSave(...[locationId, relPath, text, onOk, onErr]: DocSaveParams): Cmd { + return invokeCmd("doc_save", { locationId, relPath, text }, onOk, onErr); +} + +export function docExists(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { + return invokeCmd("doc_exists", { locationId, relPath }, onOk, onErr); +} + +export function searchDocuments(...[query, filters, limit, onOk, onErr]: SearchParams): Cmd { + return invokeCmd("search", { query, filters, limit }, onOk, onErr); +} + +export function renderMarkdown( + ...[locationId, relPath, text, profile, onOk, onErr]: RenderMarkdownParams +): Cmd { + return invokeCmd("markdown_render", { locationId, relPath, text, profile }, onOk, onErr); +} + +export function renderMarkdownForPdf( + ...[locationId, relPath, text, profile, onOk, onErr]: RenderMarkdownForPdfParams +): Cmd { + return invokeCmd("markdown_render_for_pdf", { locationId, relPath, text, profile }, onOk, onErr); +} + +export function uiLayoutGet(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("ui_layout_get", {}, onOk, onErr); +} + +export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams): Cmd { + return invokeCmd("ui_layout_set", { settings }, onOk, onErr); +} + +export function sessionLastDocGet(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("session_last_doc_get", {}, onOk, onErr); +} + +export function sessionLastDocSet(...[docRef, onOk, onErr]: SessionLastDocSetParams): Cmd { + return invokeCmd("session_last_doc_set", { docRef }, onOk, onErr); +} + +export function styleCheckGet(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("style_check_get", {}, onOk, onErr); +} + +export function styleCheckSet(...[settings, onOk, onErr]: StyleCheckSetParams): Cmd { + return invokeCmd("style_check_set", { settings }, onOk, onErr); +} + +export function globalCaptureGet(...[onOk, onErr]: GlobalCaptureGetParams): Cmd { + return invokeCmd("global_capture_get", {}, onOk, onErr); +} + +export function globalCaptureSet(...[settings, onOk, onErr]: GlobalCaptureSetParams): Cmd { + return invokeCmd("global_capture_set", { settings: toBackendGlobalCaptureSettings(settings) }, onOk, onErr); +} + +export function globalCaptureOpen(...[onOk, onErr]: LocParams): Cmd { + return invokeCmd("global_capture_open", {}, onOk, onErr); +} + +export function globalCaptureSubmit(...[input, onOk, onErr]: GlobalCaptureSubmitParams): Cmd { + return invokeCmd("global_capture_submit", toBackendCaptureSubmitInput(input), onOk, onErr); +} + +export function globalCapturePause(...[paused, onOk, onErr]: GlobalCapturePauseParams): Cmd { + return invokeCmd("global_capture_pause", { paused }, onOk, onErr); +} + +export function globalCaptureValidateShortcut(...[shortcut, onOk, onErr]: GlobalCaptureValidateShortcutParams): Cmd { + return invokeCmd("global_capture_validate_shortcut", { shortcut }, onOk, onErr); +} + +export type { MarkdownProfile, SearchFiltersPayload }; diff --git a/src/ports/index.ts b/src/ports/index.ts new file mode 100644 index 0000000..2b9cea1 --- /dev/null +++ b/src/ports/index.ts @@ -0,0 +1,3 @@ +export * from "./commands"; +export * from "./invoke"; +export * from "./types"; diff --git a/src/ports.ts b/src/ports/invoke.ts similarity index 51% rename from src/ports.ts rename to src/ports/invoke.ts index 67e82a6..776536c 100644 --- a/src/ports.ts +++ b/src/ports/invoke.ts @@ -1,174 +1,23 @@ import { logger } from "$logger"; -import type { PdfRenderResult } from "$pdf/types"; import type { AppError, CaptureDocRef, CaptureMode, - CaptureSubmitInput, CaptureSubmitResult, - DocContent, DocMeta, DocRef, - EditorFontFamily, ErrorCode, - FocusDimmingMode, GlobalCaptureSettings, - LocationDescriptor, - LocationId, - MarkdownProfile, - PatternCategory, - RenderResult, - SaveStatus, SearchHit, } from "$types"; import type { InvokeArgs } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core"; import type { Event as TauriEvent, UnlistenFn } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event"; - -export type EditorState = { - doc_ref: DocRef | null; - text: string; - save_status: SaveStatus; - cursor_line: number; - cursor_column: number; - selection_from: number | null; - selection_to: number | null; -}; - -export type EditorMsg = - | { type: "EditorChanged"; text: string } - | { type: "SaveRequested" } - | { type: "SaveFinished"; success: boolean; error?: AppError } - | { type: "DocOpened"; doc: DocContent } - | { type: "CursorMoved"; line: number; column: number } - | { type: "SelectionChanged"; from: number; to: number | null }; - -export type SaveResult = { success: boolean; new_meta: DocMeta | null; conflict_detected: boolean }; - -export type UiLayoutSettings = { - sidebar_collapsed: boolean; - top_bars_collapsed: boolean; - status_bar_collapsed: boolean; - line_numbers_visible: boolean; - text_wrapping_enabled: boolean; - syntax_highlighting_enabled: boolean; - editor_font_size: number; - editor_font_family: EditorFontFamily; - calm_ui_enabled: boolean; - calm_ui_auto_hide: boolean; - calm_ui_focus_mode: boolean; - focus_typewriter_scrolling_enabled: boolean; - focus_dimming_mode: FocusDimmingMode; -}; - -export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string }; - -export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; - -export type PersistedStyleCheckSettings = { - enabled: boolean; - categories: StyleCheckCategorySettings; - custom_patterns: StyleCheckPattern[]; -}; - -type RenderMarkdownParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; -type UiLayoutSetParams = Parameters< - (settings: UiLayoutSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void ->; - -export type BackendEvent = - | { type: "LocationMissing"; location_id: LocationId; path: string } - | { type: "LocationChanged"; location_id: LocationId; old_path: string; new_path: string } - | { type: "ReconciliationComplete"; checked: number; missing: LocationId[] } - | { type: "ConflictDetected"; location_id: LocationId; rel_path: string; conflict_filename: string } - | { type: "DocModifiedExternally"; doc_id: DocRef; new_mtime: string } - | { type: "SaveStatusChanged"; doc_id: DocRef; status: SaveStatus }; - -export type InvokeCmd = { - type: "Invoke"; - command: string; - payload: unknown; - onOk: (value: unknown) => void; - onErr: ErrorCallback; -}; -export type StartWatchCmd = { type: "StartWatch"; locationId: LocationId }; -export type StopWatchCmd = { type: "StopWatch"; locationId: LocationId }; -export type BatchCmd = { type: "Batch"; commands: Cmd[] }; -export type NoneCmd = { type: "None" }; -export type Cmd = InvokeCmd | StartWatchCmd | StopWatchCmd | BatchCmd | NoneCmd; - -type ErrorCallback = (error: AppError) => void; -type SuccessCallback = (value: T) => void; - -type LocParams = Parameters<(onOk: SuccessCallback, onErr: ErrorCallback) => void>; -type LocationIdParams = Parameters<(locationId: LocationId) => void>; -type LocationPathParams = Parameters<(locationId: LocationId, relPath: string) => void>; -type LocationPathTextParams = Parameters<(locationId: LocationId, relPath: string, text: string) => void>; -type DocListParams = [...LocationIdParams, ...LocParams]; -type DocOpenParams = [...LocationPathParams, ...LocParams]; -type DocSaveParams = [...LocationPathTextParams, ...LocParams]; -type SearchParams = Parameters< - ( - query: string, - filters: SearchFiltersPayload | undefined, - limit: number, - onOk: SuccessCallback, - onErr: ErrorCallback, - ) => void ->; - -export type SearchDateRangePayload = { from?: string; to?: string }; -export type SearchFiltersPayload = { - locations?: LocationId[]; - fileTypes?: string[]; - dateRange?: SearchDateRangePayload; -}; - -export type CmdResult = { type: "ok"; value: T } | { type: "err"; error: AppError }; +import type { BackendEvent, Cmd, CmdResult, Sub } from "./types"; type RustCommandResult = { Ok: T } | { Err: unknown }; -type BackendCaptureDocRef = { location_id: number; rel_path: string }; - -type BackendGlobalCaptureSettings = { - enabled: boolean; - shortcut: string; - paused: boolean; - default_mode: CaptureMode; - target_location_id: number | null; - inbox_relative_dir: string; - append_target: BackendCaptureDocRef | null; - close_after_save: boolean; - show_tray_icon: boolean; - last_capture_target: string | null; -}; - -type BackendCaptureSubmitInput = { - mode: CaptureMode; - text: string; - destination?: BackendCaptureDocRef; - open_main_after_save?: boolean; -}; - -export type BackendEventsSub = { type: "BackendEvents"; onEvent: (event: BackendEvent) => void }; -export type NoneSub = { type: "None" }; -export type Sub = BackendEventsSub | NoneSub; - -type RenderMarkdownForPdfParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; - -export const ok = (value: T): CmdResult => ({ type: "ok", value }); - -export const err = (error: AppError): CmdResult => ({ type: "err", error }); - -export function isOk(result: CmdResult): result is { type: "ok"; value: T } { - return result.type === "ok"; -} - -export function isErr(result: CmdResult): result is { type: "err"; error: AppError } { - return result.type === "err"; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -387,40 +236,6 @@ function normalizeCaptureSubmitResult(value: unknown): CaptureSubmitResult { }; } -function toBackendCaptureDocRef(value: CaptureDocRef | null): BackendCaptureDocRef | null { - if (!value) { - return null; - } - - return { location_id: value.locationId, rel_path: value.relPath }; -} - -function toBackendGlobalCaptureSettings(settings: GlobalCaptureSettings): BackendGlobalCaptureSettings { - return { - enabled: settings.enabled, - shortcut: settings.shortcut, - paused: settings.paused, - default_mode: settings.defaultMode, - target_location_id: settings.targetLocationId, - inbox_relative_dir: settings.inboxRelativeDir, - append_target: toBackendCaptureDocRef(settings.appendTarget), - close_after_save: settings.closeAfterSave, - show_tray_icon: settings.showTrayIcon, - last_capture_target: settings.lastCaptureTarget, - }; -} - -function toBackendCaptureSubmitInput(input: CaptureSubmitInput): BackendCaptureSubmitInput { - return { - mode: input.mode, - text: input.text, - destination: input.destination - ? { location_id: input.destination.locationId, rel_path: input.destination.relPath } - : void 0, - open_main_after_save: input.openMainAfterSave, - }; -} - function normalizeCommandValue(command: string, value: unknown): unknown { switch (command) { case "doc_list": { @@ -465,15 +280,15 @@ function normalizeCommandValue(command: string, value: unknown): unknown { export function invokeCmd( command: string, payload: unknown, - onOk: (value: T) => void, - onErr: (error: AppError) => void, + onOk: (value: T) => unknown, + onErr: (error: AppError) => unknown, ): Cmd { - return { type: "Invoke", command, payload, onOk: onOk as (value: unknown) => void, onErr }; + return { type: "Invoke", command, payload, onOk: onOk as (value: unknown) => unknown, onErr }; } -export const startWatch = (locationId: LocationId): Cmd => ({ type: "StartWatch", locationId }); +export const startWatch = (locationId: number): Cmd => ({ type: "StartWatch", locationId }); -export const stopWatch = (locationId: LocationId): Cmd => ({ type: "StopWatch", locationId }); +export const stopWatch = (locationId: number): Cmd => ({ type: "StopWatch", locationId }); export const batch = (commands: Cmd[]): Cmd => ({ type: "Batch", commands }); @@ -485,10 +300,6 @@ export function backendEvents(onEvent: (event: BackendEvent) => void): Sub { export const noSub: Sub = { type: "None" }; -/** - * Executes a command, invoking the Tauri backend and routing the result - * through the standard response envelope. - */ export async function runCmd(cmd: Cmd): Promise { switch (cmd.type) { case "Invoke": { @@ -568,9 +379,6 @@ export async function runCmd(cmd: Cmd): Promise { export class SubscriptionManager { private unlistenFns = new Map(); - /** - * Activates a subscription, returning a cleanup function. - */ async subscribe(sub: Sub): Promise<() => void> { switch (sub.type) { case "BackendEvents": { @@ -607,120 +415,6 @@ export class SubscriptionManager { } } -export function locationAddViaDialog(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("location_add_via_dialog", {}, onOk, onErr); -} - -export function locationList(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("location_list", {}, onOk, onErr); -} - -export function locationRemove(...[locationId, onOk, onErr]: DocListParams): Cmd { - return invokeCmd("location_remove", { locationId }, onOk, onErr); -} - -export function locationValidate(...[onOk, onErr]: LocParams>): Cmd { - return invokeCmd>("location_validate", {}, onOk, onErr); -} - -export function docList(...[locationId, onOk, onErr]: DocListParams): Cmd { - return invokeCmd("doc_list", { locationId }, onOk, onErr); -} - -export function docOpen(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { - return invokeCmd("doc_open", { locationId, relPath }, onOk, onErr); -} - -export function docSave(...[locationId, relPath, text, onOk, onErr]: DocSaveParams): Cmd { - return invokeCmd("doc_save", { locationId, relPath, text }, onOk, onErr); -} - -export function docExists(...[locationId, relPath, onOk, onErr]: DocOpenParams): Cmd { - return invokeCmd("doc_exists", { locationId, relPath }, onOk, onErr); -} - -export function searchDocuments(...[query, filters, limit, onOk, onErr]: SearchParams): Cmd { - return invokeCmd("search", { query, filters, limit }, onOk, onErr); -} - -export function renderMarkdown( - ...[locationId, relPath, text, profile, onOk, onErr]: RenderMarkdownParams -): Cmd { - return invokeCmd("markdown_render", { locationId, relPath, text, profile }, onOk, onErr); -} - -export function renderMarkdownForPdf( - ...[locationId, relPath, text, profile, onOk, onErr]: RenderMarkdownForPdfParams -): Cmd { - return invokeCmd("markdown_render_for_pdf", { locationId, relPath, text, profile }, onOk, onErr); -} - -export function uiLayoutGet(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("ui_layout_get", {}, onOk, onErr); -} - -export function uiLayoutSet(...[settings, onOk, onErr]: UiLayoutSetParams): Cmd { - return invokeCmd("ui_layout_set", { settings }, onOk, onErr); -} - -type SessionLastDocSetParams = Parameters< - (docRef: DocRef | null, onOk: SuccessCallback, onErr: ErrorCallback) => void ->; - -export function sessionLastDocGet(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("session_last_doc_get", {}, onOk, onErr); -} - -export function sessionLastDocSet(...[docRef, onOk, onErr]: SessionLastDocSetParams): Cmd { - return invokeCmd("session_last_doc_set", { docRef }, onOk, onErr); -} - -type StyleCheckSetParams = Parameters< - (settings: PersistedStyleCheckSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void ->; - -export function styleCheckGet(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("style_check_get", {}, onOk, onErr); -} - -export function styleCheckSet(...[settings, onOk, onErr]: StyleCheckSetParams): Cmd { - return invokeCmd("style_check_set", { settings }, onOk, onErr); -} - -export function globalCaptureGet(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("global_capture_get", {}, onOk, onErr); -} - -export function globalCaptureSet( - ...[settings, onOk, onErr]: Parameters< - (settings: GlobalCaptureSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void - > -): Cmd { - return invokeCmd("global_capture_set", { settings: toBackendGlobalCaptureSettings(settings) }, onOk, onErr); -} - -export function globalCaptureOpen(...[onOk, onErr]: LocParams): Cmd { - return invokeCmd("global_capture_open", {}, onOk, onErr); -} - -export function globalCaptureSubmit( - ...[input, onOk, onErr]: Parameters< - (input: CaptureSubmitInput, onOk: SuccessCallback, onErr: ErrorCallback) => void - > -): Cmd { - return invokeCmd("global_capture_submit", toBackendCaptureSubmitInput(input), onOk, onErr); -} - -export function globalCapturePause( - ...[paused, onOk, onErr]: Parameters<(paused: boolean, onOk: SuccessCallback, onErr: ErrorCallback) => void> -): Cmd { - return invokeCmd("global_capture_pause", { paused }, onOk, onErr); -} - -export function globalCaptureValidateShortcut( - ...[shortcut, onOk, onErr]: Parameters< - (shortcut: string, onOk: SuccessCallback, onErr: ErrorCallback) => void - > -): Cmd { - return invokeCmd("global_capture_validate_shortcut", { shortcut }, onOk, onErr); +export function toCmdResult(value: T): CmdResult { + return { type: "ok", value }; } diff --git a/src/ports/types.ts b/src/ports/types.ts new file mode 100644 index 0000000..f90cdcc --- /dev/null +++ b/src/ports/types.ts @@ -0,0 +1,186 @@ +import type { PdfRenderResult } from "$pdf/types"; +import type { + AppError, + CaptureMode, + CaptureSubmitInput, + CaptureSubmitResult, + DocContent, + DocMeta, + DocRef, + EditorFontFamily, + FocusDimmingMode, + GlobalCaptureSettings, + LocationId, + MarkdownProfile, + PatternCategory, + RenderResult, + SaveStatus, + SearchHit, +} from "$types"; + +export type EditorState = { + doc_ref: DocRef | null; + text: string; + save_status: SaveStatus; + cursor_line: number; + cursor_column: number; + selection_from: number | null; + selection_to: number | null; +}; + +export type EditorMsg = + | { type: "EditorChanged"; text: string } + | { type: "SaveRequested" } + | { type: "SaveFinished"; success: boolean; error?: AppError } + | { type: "DocOpened"; doc: DocContent } + | { type: "CursorMoved"; line: number; column: number } + | { type: "SelectionChanged"; from: number; to: number | null }; + +export type SaveResult = { success: boolean; new_meta: DocMeta | null; conflict_detected: boolean }; + +export type UiLayoutSettings = { + sidebar_collapsed: boolean; + top_bars_collapsed: boolean; + status_bar_collapsed: boolean; + line_numbers_visible: boolean; + text_wrapping_enabled: boolean; + syntax_highlighting_enabled: boolean; + editor_font_size: number; + editor_font_family: EditorFontFamily; + calm_ui_enabled: boolean; + calm_ui_focus_mode: boolean; + focus_typewriter_scrolling_enabled: boolean; + focus_dimming_mode: FocusDimmingMode; +}; + +export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string }; + +export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; + +export type PersistedStyleCheckSettings = { + enabled: boolean; + categories: StyleCheckCategorySettings; + custom_patterns: StyleCheckPattern[]; +}; + +export type BackendEvent = + | { type: "LocationMissing"; location_id: LocationId; path: string } + | { type: "LocationChanged"; location_id: LocationId; old_path: string; new_path: string } + | { type: "ReconciliationComplete"; checked: number; missing: LocationId[] } + | { type: "ConflictDetected"; location_id: LocationId; rel_path: string; conflict_filename: string } + | { type: "DocModifiedExternally"; doc_id: DocRef; new_mtime: string } + | { type: "SaveStatusChanged"; doc_id: DocRef; status: SaveStatus }; + +export type ErrorCallback = (error: AppError) => void; +export type SuccessCallback = (value: T) => void; + +export type InvokeCmd = { + type: "Invoke"; + command: string; + payload: unknown; + onOk: (value: unknown) => unknown; + onErr: (error: AppError) => unknown; +}; +export type StartWatchCmd = { type: "StartWatch"; locationId: LocationId }; +export type StopWatchCmd = { type: "StopWatch"; locationId: LocationId }; +export type BatchCmd = { type: "Batch"; commands: Cmd[] }; +export type NoneCmd = { type: "None" }; +export type Cmd = InvokeCmd | StartWatchCmd | StopWatchCmd | BatchCmd | NoneCmd; + +export type BackendEventsSub = { type: "BackendEvents"; onEvent: (event: BackendEvent) => void }; +export type NoneSub = { type: "None" }; +export type Sub = BackendEventsSub | NoneSub; + +export type LocParams = Parameters<(onOk: SuccessCallback, onErr: ErrorCallback) => void>; +export type LocationIdParams = Parameters<(locationId: LocationId) => void>; +export type LocationPathParams = Parameters<(locationId: LocationId, relPath: string) => void>; +export type LocationPathTextParams = Parameters<(locationId: LocationId, relPath: string, text: string) => void>; +export type DocListParams = [...LocationIdParams, ...LocParams]; +export type DocOpenParams = [...LocationPathParams, ...LocParams]; +export type DocSaveParams = [...LocationPathTextParams, ...LocParams]; + +export type SearchDateRangePayload = { from?: string; to?: string }; +export type SearchFiltersPayload = { + locations?: LocationId[]; + fileTypes?: string[]; + dateRange?: SearchDateRangePayload; +}; + +export type SearchParams = Parameters< + ( + query: string, + filters: SearchFiltersPayload | undefined, + limit: number, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void +>; + +export type RenderMarkdownParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; +export type RenderMarkdownForPdfParams = [...LocationPathTextParams, profile?: MarkdownProfile, ...LocParams]; + +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 StyleCheckSetParams = Parameters< + (settings: PersistedStyleCheckSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type SearchResult = SearchHit[]; +export type MarkdownRenderResult = RenderResult; +export type PdfMarkdownRenderResult = PdfRenderResult; + +export type CmdResult = { type: "ok"; value: T } | { type: "err"; error: AppError }; + +export const ok = (value: T): CmdResult => ({ type: "ok", value }); + +export const err = (error: AppError): CmdResult => ({ type: "err", error }); + +export function isOk(result: CmdResult): result is { type: "ok"; value: T } { + return result.type === "ok"; +} + +export function isErr(result: CmdResult): result is { type: "err"; error: AppError } { + return result.type === "err"; +} + +export type BackendCaptureDocRef = { location_id: number; rel_path: string }; + +export type BackendGlobalCaptureSettings = { + enabled: boolean; + shortcut: string; + paused: boolean; + default_mode: CaptureMode; + target_location_id: number | null; + inbox_relative_dir: string; + append_target: BackendCaptureDocRef | null; + close_after_save: boolean; + show_tray_icon: boolean; + last_capture_target: string | null; +}; + +export type BackendCaptureSubmitInput = { + mode: CaptureMode; + text: string; + destination?: BackendCaptureDocRef; + open_main_after_save?: boolean; +}; + +export type GlobalCaptureGetParams = LocParams; +export type GlobalCaptureSetParams = Parameters< + (settings: GlobalCaptureSettings, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; +export type GlobalCaptureSubmitParams = Parameters< + (input: CaptureSubmitInput, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; +export type GlobalCapturePauseParams = Parameters< + (paused: boolean, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; +export type GlobalCaptureValidateShortcutParams = Parameters< + (shortcut: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; diff --git a/src/state/atoms/search.ts b/src/state/atoms/search.ts deleted file mode 100644 index c5335d0..0000000 --- a/src/state/atoms/search.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { SearchFilters } from "$components/SearchPanel"; -import type { SearchHit } from "$types"; -import { atom } from "jotai"; - -export const searchQueryAtom = atom(""); -export const searchResultsAtom = atom([]); -export const isSearchingAtom = atom(false); -export const searchFiltersAtom = atom({}); - -export const activeSearchFilterCountAtom = atom((get) => { - const filters = get(searchFiltersAtom); - return (filters.locations?.length ?? 0) + (filters.fileTypes?.length ?? 0) + (filters.dateRange ? 1 : 0); -}); - -export const resetSearchAtom = atom(null, (_get, set) => { - set(searchQueryAtom, ""); - set(searchResultsAtom, []); - set(isSearchingAtom, false); - set(searchFiltersAtom, {}); -}); diff --git a/src/state/atoms/ui.ts b/src/state/atoms/ui.ts deleted file mode 100644 index 702d2b2..0000000 --- a/src/state/atoms/ui.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { logger } from "$logger"; -import { DEFAULT_OPTIONS } from "$pdf/constants"; -import type { MarginSide, PdfExportOptions } from "$pdf/types"; -import { globalCaptureSet, runCmd } from "$ports"; -import type { GlobalCaptureSettings } from "$types"; -import { atom } from "jotai"; - -const DEFAULT_GLOBAL_CAPTURE_SETTINGS: GlobalCaptureSettings = { - enabled: true, - shortcut: "CommandOrControl+Shift+Space", - paused: false, - defaultMode: "QuickNote", - targetLocationId: null, - inboxRelativeDir: "inbox", - appendTarget: null, - closeAfterSave: true, - showTrayIcon: true, - lastCaptureTarget: null, -}; - -export const layoutSettingsOpenAtom = atom(false); -export const pdfExportDialogOpenAtom = atom(false); -export const pdfExportOptionsAtom = atom(DEFAULT_OPTIONS); - -export const globalCaptureSettingsAtom = atom(DEFAULT_GLOBAL_CAPTURE_SETTINGS); - -export const setQuickCaptureEnabledAtom = atom(null, (get, set, enabled: boolean) => { - const previous = get(globalCaptureSettingsAtom); - if (previous.enabled === enabled) { - return; - } - - const next = { ...previous, enabled }; - set(globalCaptureSettingsAtom, next); - - void runCmd(globalCaptureSet(next, () => {}, (error) => { - logger.error("Failed to persist quick capture enabled state", error); - set(globalCaptureSettingsAtom, previous); - })); -}); - -export const resetPdfExportOptionsAtom = atom(null, (_get, set) => { - set(pdfExportOptionsAtom, DEFAULT_OPTIONS); -}); - -export const setPdfPageSizeAtom = atom(null, (_get, set, pageSize: PdfExportOptions["pageSize"]) => { - set(pdfExportOptionsAtom, (prev) => ({ ...prev, pageSize })); -}); - -export const setPdfOrientationAtom = atom(null, (_get, set, orientation: PdfExportOptions["orientation"]) => { - set(pdfExportOptionsAtom, (prev) => ({ ...prev, orientation })); -}); - -export const setPdfFontSizeAtom = atom(null, (_get, set, fontSize: number) => { - set(pdfExportOptionsAtom, (prev) => ({ ...prev, fontSize })); -}); - -export const setPdfMarginAtom = atom(null, (_get, set, payload: { side: MarginSide; value: number }) => { - set( - pdfExportOptionsAtom, - (prev) => ({ - ...prev, - margins: { ...prev.margins, [payload.side]: Number.isNaN(payload.value) ? 0 : payload.value }, - }), - ); -}); - -export const setPdfIncludeHeaderAtom = atom(null, (_get, set, includeHeader: boolean) => { - set(pdfExportOptionsAtom, (prev) => ({ ...prev, includeHeader })); -}); - -export const setPdfIncludeFooterAtom = atom(null, (_get, set, includeFooter: boolean) => { - set(pdfExportOptionsAtom, (prev) => ({ ...prev, includeFooter })); -}); diff --git a/src/state/helpers.ts b/src/state/helpers.ts index 13143ec..015ee58 100644 --- a/src/state/helpers.ts +++ b/src/state/helpers.ts @@ -16,7 +16,6 @@ export const stateToLayoutSettings = ( editor_font_size: presentation.editorFontSize, editor_font_family: presentation.editorFontFamily, calm_ui_enabled: layoutChrome.calmUiSettings.enabled, - calm_ui_auto_hide: layoutChrome.calmUiSettings.autoHide, calm_ui_focus_mode: layoutChrome.calmUiSettings.focusMode, focus_typewriter_scrolling_enabled: focusModeSettings.typewriterScrollingEnabled, focus_dimming_mode: focusModeSettings.dimmingMode, @@ -24,7 +23,6 @@ export const stateToLayoutSettings = ( export const uiSettingsToCalmUI: (uiSettings: UiLayoutSettings) => CalmUiSettings = (uiSettings) => ({ enabled: uiSettings.calm_ui_enabled, - autoHide: uiSettings.calm_ui_auto_hide, focusMode: uiSettings.calm_ui_focus_mode, }); diff --git a/src/state/panel-selectors.ts b/src/state/panel-selectors.ts deleted file mode 100644 index 69aed04..0000000 --- a/src/state/panel-selectors.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { CalmUiSettings, StyleCheckSettings } from "$types"; -import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "./stores/app"; -import { EditorPresentation } from "./types"; - -const FOCUS_MODE_STYLE_CHECK_SETTINGS: StyleCheckSettings = { - enabled: false, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], -}; - -export const useLayoutSettingsChromeState = () => - useAppStore( - useShallow((state) => ({ - sidebarCollapsed: state.sidebarCollapsed, - topBarsCollapsed: state.topBarsCollapsed, - statusBarCollapsed: state.statusBarCollapsed, - toggleSidebarCollapsed: state.toggleSidebarCollapsed, - toggleTabBarCollapsed: state.toggleTabBarCollapsed, - toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, - })), - ); - -export const useLayoutSettingsEditorState = () => - useAppStore( - useShallow((state) => ({ - lineNumbersVisible: state.lineNumbersVisible, - textWrappingEnabled: state.textWrappingEnabled, - syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, - editorFontSize: state.editorFontSize, - editorFontFamily: state.editorFontFamily, - toggleLineNumbersVisible: state.toggleLineNumbersVisible, - toggleTextWrappingEnabled: state.toggleTextWrappingEnabled, - toggleSyntaxHighlightingEnabled: state.toggleSyntaxHighlightingEnabled, - setEditorFontSize: state.setEditorFontSize, - setEditorFontFamily: state.setEditorFontFamily, - })), - ); - -export const useLayoutSettingsFocusState = () => - useAppStore( - useShallow((state) => ({ - focusModeSettings: state.focusModeSettings, - setTypewriterScrollingEnabled: state.setTypewriterScrollingEnabled, - setFocusDimmingMode: state.setFocusDimmingMode, - })), - ); - -export const useLayoutSettingsWriterToolsState = () => - useAppStore( - useShallow((state) => ({ - posHighlightingEnabled: state.posHighlightingEnabled, - styleCheckSettings: state.styleCheckSettings, - togglePosHighlighting: state.togglePosHighlighting, - setStyleCheckSettings: state.setStyleCheckSettings, - setStyleCheckCategory: state.setStyleCheckCategory, - addCustomPattern: state.addCustomPattern, - removeCustomPattern: state.removeCustomPattern, - })), - ); - -export const useAppHeaderBarState = () => - useAppStore( - useShallow((state) => ({ - tabBarCollapsed: state.topBarsCollapsed, - toggleSidebarCollapsed: state.toggleSidebarCollapsed, - toggleTabBarCollapsed: state.toggleTabBarCollapsed, - setShowSearch: state.setShowSearch, - })), - ); - -export const useWorkspacePanelSidebarState = () => - useAppStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed }))); - -export const useWorkspacePanelModeState = () => - useAppStore(useShallow((state) => ({ isSplitView: state.isSplitView, isPreviewVisible: state.isPreviewVisible }))); - -export const useWorkspacePanelTopBarsCollapsed = () => useAppStore((state) => state.topBarsCollapsed); - -export const useWorkspacePanelStatusBarCollapsed = () => useAppStore((state) => state.statusBarCollapsed); - -export const useSearchOverlayState = () => - useAppStore(useShallow((state) => ({ isVisible: state.showSearch, setShowSearch: state.setShowSearch }))); - -export const useFocusModePanelState = () => - useAppStore( - useShallow((state) => ({ statusBarCollapsed: state.statusBarCollapsed, setFocusMode: state.setFocusMode })), - ); - -export const useSidebarState = () => - useAppStore( - useShallow((state) => ({ - locations: state.locations, - selectedLocationId: state.selectedLocationId, - selectedDocPath: state.selectedDocPath, - documents: state.documents, - isLoading: state.isLoadingLocations || state.isLoadingDocuments, - filterText: state.sidebarFilter, - setFilterText: state.setSidebarFilter, - selectLocation: state.setSelectedLocation, - toggleSidebarCollapsed: state.toggleSidebarCollapsed, - })), - ); - -export const useToolbarState = () => - useAppStore( - useShallow((state) => ({ - isSplitView: state.isSplitView, - isFocusMode: state.isFocusMode, - isPreviewVisible: state.isPreviewVisible, - toggleSplitView: state.toggleSplitView, - toggleFocusMode: state.toggleFocusMode, - togglePreviewVisible: state.togglePreviewVisible, - })), - ); - -export const useEditorPresentationState = () => - useAppStore( - useShallow((state): EditorPresentation => ({ - theme: state.theme, - showLineNumbers: state.isFocusMode ? false : state.lineNumbersVisible, - textWrappingEnabled: state.textWrappingEnabled, - syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, - fontSize: state.editorFontSize, - fontFamily: state.editorFontFamily, - typewriterScrollingEnabled: state.isFocusMode ? state.focusModeSettings.typewriterScrollingEnabled : false, - focusDimmingMode: state.isFocusMode ? state.focusModeSettings.dimmingMode : "off", - posHighlightingEnabled: state.posHighlightingEnabled, - styleCheckSettings: state.isFocusMode ? FOCUS_MODE_STYLE_CHECK_SETTINGS : state.styleCheckSettings, - })), - ); - -export const useCalmUiSettings = () => - useAppStore( - useShallow((state): CalmUiSettings & { chromeTemporarilyVisible: boolean } => ({ - ...state.calmUiSettings, - chromeTemporarilyVisible: state.chromeTemporarilyVisible, - })), - ); - -export const useCalmUiActions = () => - useAppStore( - useShallow((state) => ({ - setCalmUiSettings: state.setCalmUiSettings, - toggleCalmUi: state.toggleCalmUi, - setCalmUiAutoHide: state.setCalmUiAutoHide, - setCalmUiFocusMode: state.setCalmUiFocusMode, - setChromeTemporarilyVisible: state.setChromeTemporarilyVisible, - revealChromeTemporarily: state.revealChromeTemporarily, - })), - ); - -export type SidebarStateReturn = ReturnType; -export type ToolbarStateReturn = ReturnType; -export type EditorPresentationStateReturn = ReturnType; -export type WorkspacePanelSidebarStateReturn = ReturnType; -export type WorkspacePanelModeStateReturn = ReturnType; -export type TopBarsCollapsedReturn = ReturnType; -export type StatusBarCollapsedReturn = ReturnType; diff --git a/src/state/selectors.ts b/src/state/selectors.ts new file mode 100644 index 0000000..750e5e3 --- /dev/null +++ b/src/state/selectors.ts @@ -0,0 +1,394 @@ +import type { CalmUiSettings, StyleCheckSettings } from "$types"; +import { useShallow } from "zustand/react/shallow"; +import { useAppStore } from "./stores/app"; +import type { EditorPresentation } from "./types"; + +const FOCUS_MODE_STYLE_CHECK_SETTINGS: StyleCheckSettings = { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], +}; + +export const useLayoutChromeState = () => + useAppStore( + useShallow((state) => ({ + sidebarCollapsed: state.sidebarCollapsed, + topBarsCollapsed: state.topBarsCollapsed, + statusBarCollapsed: state.statusBarCollapsed, + showSearch: state.showSearch, + calmUiSettings: state.calmUiSettings, + chromeTemporarilyVisible: state.chromeTemporarilyVisible, + })), + ); + +export const useLayoutChromeActions = () => + useAppStore( + useShallow((state) => ({ + setSidebarCollapsed: state.setSidebarCollapsed, + toggleSidebarCollapsed: state.toggleSidebarCollapsed, + setTopBarsCollapsed: state.setTopBarsCollapsed, + toggleTabBarCollapsed: state.toggleTabBarCollapsed, + setStatusBarCollapsed: state.setStatusBarCollapsed, + toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, + setShowSearch: state.setShowSearch, + toggleShowSearch: state.toggleShowSearch, + setCalmUiSettings: state.setCalmUiSettings, + toggleCalmUi: state.toggleCalmUi, + setCalmUiFocusMode: state.setCalmUiFocusMode, + setChromeTemporarilyVisible: state.setChromeTemporarilyVisible, + revealChromeTemporarily: state.revealChromeTemporarily, + })), + ); + +export const useEditorPresentationStateRaw = () => + useAppStore( + useShallow((state) => ({ + lineNumbersVisible: state.lineNumbersVisible, + textWrappingEnabled: state.textWrappingEnabled, + syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, + editorFontSize: state.editorFontSize, + editorFontFamily: state.editorFontFamily, + theme: state.theme, + })), + ); + +export const useEditorPresentationActions = () => + useAppStore( + useShallow((state) => ({ + setLineNumbersVisible: state.setLineNumbersVisible, + toggleLineNumbersVisible: state.toggleLineNumbersVisible, + setTextWrappingEnabled: state.setTextWrappingEnabled, + toggleTextWrappingEnabled: state.toggleTextWrappingEnabled, + setSyntaxHighlightingEnabled: state.setSyntaxHighlightingEnabled, + toggleSyntaxHighlightingEnabled: state.toggleSyntaxHighlightingEnabled, + setEditorFontSize: state.setEditorFontSize, + setEditorFontFamily: state.setEditorFontFamily, + })), + ); + +export const useViewModeState = () => + useAppStore( + useShallow((state) => ({ + isSplitView: state.isSplitView, + isFocusMode: state.isFocusMode, + isPreviewVisible: state.isPreviewVisible, + focusModeSettings: state.focusModeSettings, + })), + ); + +export const useViewModeActions = () => + useAppStore( + useShallow((state) => ({ + setSplitView: state.setSplitView, + toggleSplitView: state.toggleSplitView, + setEditorOnlyMode: state.setEditorOnlyMode, + setFocusMode: state.setFocusMode, + toggleFocusMode: state.toggleFocusMode, + setFocusModeSettings: state.setFocusModeSettings, + setTypewriterScrollingEnabled: state.setTypewriterScrollingEnabled, + setFocusDimmingMode: state.setFocusDimmingMode, + toggleTypewriterScrolling: state.toggleTypewriterScrolling, + setPreviewVisible: state.setPreviewVisible, + togglePreviewVisible: state.togglePreviewVisible, + })), + ); + +export const useWriterToolsState = () => + useAppStore( + useShallow((state) => ({ + posHighlightingEnabled: state.posHighlightingEnabled, + styleCheckSettings: state.styleCheckSettings, + })), + ); + +export const useWriterToolsActions = () => + useAppStore( + useShallow((state) => ({ + setPosHighlightingEnabled: state.setPosHighlightingEnabled, + togglePosHighlighting: state.togglePosHighlighting, + setStyleCheckSettings: state.setStyleCheckSettings, + toggleStyleCheck: state.toggleStyleCheck, + setStyleCheckCategory: state.setStyleCheckCategory, + addCustomPattern: state.addCustomPattern, + removeCustomPattern: state.removeCustomPattern, + })), + ); + +export const useWorkspaceLocationsState = () => + useAppStore( + useShallow((state) => ({ + locations: state.locations, + isLoadingLocations: state.isLoadingLocations, + selectedLocationId: state.selectedLocationId, + sidebarFilter: state.sidebarFilter, + })), + ); + +export const useWorkspaceLocationsActions = () => + useAppStore( + useShallow((state) => ({ + setSidebarFilter: state.setSidebarFilter, + setLocations: state.setLocations, + setLoadingLocations: state.setLoadingLocations, + setSelectedLocation: state.setSelectedLocation, + addLocation: state.addLocation, + removeLocation: state.removeLocation, + })), + ); + +export const useWorkspaceDocumentsState = () => + useAppStore( + useShallow((state) => ({ + selectedDocPath: state.selectedDocPath, + documents: state.documents, + isLoadingDocuments: state.isLoadingDocuments, + refreshingLocationId: state.refreshingLocationId, + sidebarRefreshReason: state.sidebarRefreshReason, + })), + ); + +export const useWorkspaceDocumentsActions = () => + useAppStore( + useShallow((state) => ({ + setSelectedDocPath: state.setSelectedDocPath, + setDocuments: state.setDocuments, + setLoadingDocuments: state.setLoadingDocuments, + setSidebarRefreshState: state.setSidebarRefreshState, + })), + ); + +export const useTabsState = () => + useAppStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }))); + +export const useTabsActions = () => + useAppStore( + useShallow((state) => ({ + openDocumentTab: state.openDocumentTab, + selectTab: state.selectTab, + closeTab: state.closeTab, + reorderTabs: state.reorderTabs, + markActiveTabModified: state.markActiveTabModified, + })), + ); + +export const usePdfExportState = () => + useAppStore(useShallow((state) => ({ isExportingPdf: state.isExportingPdf, pdfExportError: state.pdfExportError }))); + +export const usePdfExportActions = () => + useAppStore( + useShallow((state) => ({ + startPdfExport: state.startPdfExport, + finishPdfExport: state.finishPdfExport, + failPdfExport: state.failPdfExport, + resetPdfExport: state.resetPdfExport, + })), + ); + +export const useSearchState = () => + useAppStore( + useShallow((state) => ({ + searchQuery: state.searchQuery, + searchResults: state.searchResults, + isSearching: state.isSearching, + filters: state.searchFilters, + })), + ); + +export const useSearchActions = () => + useAppStore( + useShallow((state) => ({ + setSearchQuery: state.setSearchQuery, + setSearchResults: state.setSearchResults, + setIsSearching: state.setIsSearching, + setFilters: state.setSearchFilters, + resetSearch: state.resetSearch, + })), + ); + +export const useActiveSearchFilterCount = () => + useAppStore((state) => { + const filters = state.searchFilters; + return (filters.locations?.length ?? 0) + (filters.fileTypes?.length ?? 0) + (filters.dateRange ? 1 : 0); + }); + +export const useLayoutSettingsUiState = () => + useAppStore(useShallow((state) => ({ isOpen: state.layoutSettingsOpen, setOpen: state.setLayoutSettingsOpen }))); + +export const usePdfDialogUiState = () => + useAppStore( + useShallow((state) => ({ + isOpen: state.pdfExportDialogOpen, + setOpen: state.setPdfExportDialogOpen, + options: state.pdfExportOptions, + setOptions: state.setPdfExportOptions, + resetOptions: state.resetPdfExportOptions, + setPageSize: state.setPdfPageSize, + setOrientation: state.setPdfOrientation, + setFontSize: state.setPdfFontSize, + setMargin: state.setPdfMargin, + setIncludeHeader: state.setPdfIncludeHeader, + setIncludeFooter: state.setPdfIncludeFooter, + })), + ); + +export const useGlobalCaptureSettingsState = () => + useAppStore( + useShallow((state) => ({ + settings: state.globalCaptureSettings, + setSettings: state.setGlobalCaptureSettings, + setQuickCaptureEnabled: state.setQuickCaptureEnabled, + })), + ); + +export const useLayoutSettingsChromeState = () => + useAppStore( + useShallow((state) => ({ + sidebarCollapsed: state.sidebarCollapsed, + topBarsCollapsed: state.topBarsCollapsed, + statusBarCollapsed: state.statusBarCollapsed, + toggleSidebarCollapsed: state.toggleSidebarCollapsed, + toggleTabBarCollapsed: state.toggleTabBarCollapsed, + toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, + })), + ); + +export const useLayoutSettingsEditorState = () => + useAppStore( + useShallow((state) => ({ + lineNumbersVisible: state.lineNumbersVisible, + textWrappingEnabled: state.textWrappingEnabled, + syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, + editorFontSize: state.editorFontSize, + editorFontFamily: state.editorFontFamily, + toggleLineNumbersVisible: state.toggleLineNumbersVisible, + toggleTextWrappingEnabled: state.toggleTextWrappingEnabled, + toggleSyntaxHighlightingEnabled: state.toggleSyntaxHighlightingEnabled, + setEditorFontSize: state.setEditorFontSize, + setEditorFontFamily: state.setEditorFontFamily, + })), + ); + +export const useLayoutSettingsFocusState = () => + useAppStore( + useShallow((state) => ({ + focusModeSettings: state.focusModeSettings, + setTypewriterScrollingEnabled: state.setTypewriterScrollingEnabled, + setFocusDimmingMode: state.setFocusDimmingMode, + })), + ); + +export const useLayoutSettingsWriterToolsState = () => + useAppStore( + useShallow((state) => ({ + posHighlightingEnabled: state.posHighlightingEnabled, + styleCheckSettings: state.styleCheckSettings, + togglePosHighlighting: state.togglePosHighlighting, + setStyleCheckSettings: state.setStyleCheckSettings, + setStyleCheckCategory: state.setStyleCheckCategory, + addCustomPattern: state.addCustomPattern, + removeCustomPattern: state.removeCustomPattern, + })), + ); + +export const useAppHeaderBarState = () => + useAppStore( + useShallow((state) => ({ + tabBarCollapsed: state.topBarsCollapsed, + statusBarCollapsed: state.statusBarCollapsed, + toggleSidebarCollapsed: state.toggleSidebarCollapsed, + toggleTabBarCollapsed: state.toggleTabBarCollapsed, + toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, + setShowSearch: state.setShowSearch, + })), + ); + +export const useWorkspacePanelSidebarState = () => + useAppStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed }))); + +export const useWorkspacePanelModeState = () => + useAppStore(useShallow((state) => ({ isSplitView: state.isSplitView, isPreviewVisible: state.isPreviewVisible }))); + +export const useWorkspacePanelTopBarsCollapsed = () => useAppStore((state) => state.topBarsCollapsed); + +export const useWorkspacePanelStatusBarCollapsed = () => useAppStore((state) => state.statusBarCollapsed); + +export const useSearchOverlayState = () => + useAppStore(useShallow((state) => ({ isVisible: state.showSearch, setShowSearch: state.setShowSearch }))); + +export const useFocusModePanelState = () => + useAppStore( + useShallow((state) => ({ statusBarCollapsed: state.statusBarCollapsed, setFocusMode: state.setFocusMode })), + ); + +export const useSidebarState = () => + useAppStore( + useShallow((state) => ({ + locations: state.locations, + selectedLocationId: state.selectedLocationId, + selectedDocPath: state.selectedDocPath, + documents: state.documents, + isLoading: state.isLoadingLocations || state.isLoadingDocuments, + refreshingLocationId: state.refreshingLocationId, + sidebarRefreshReason: state.sidebarRefreshReason, + filterText: state.sidebarFilter, + setFilterText: state.setSidebarFilter, + selectLocation: state.setSelectedLocation, + toggleSidebarCollapsed: state.toggleSidebarCollapsed, + })), + ); + +export const useToolbarState = () => + useAppStore( + useShallow((state) => ({ + isSplitView: state.isSplitView, + isFocusMode: state.isFocusMode, + isPreviewVisible: state.isPreviewVisible, + setEditorOnlyMode: state.setEditorOnlyMode, + toggleSplitView: state.toggleSplitView, + toggleFocusMode: state.toggleFocusMode, + togglePreviewVisible: state.togglePreviewVisible, + })), + ); + +export const useEditorPresentationState = () => + useAppStore( + useShallow((state): EditorPresentation => ({ + theme: state.theme, + showLineNumbers: state.isFocusMode ? false : state.lineNumbersVisible, + textWrappingEnabled: state.textWrappingEnabled, + syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, + fontSize: state.editorFontSize, + fontFamily: state.editorFontFamily, + typewriterScrollingEnabled: state.isFocusMode ? state.focusModeSettings.typewriterScrollingEnabled : false, + focusDimmingMode: state.isFocusMode ? state.focusModeSettings.dimmingMode : "off", + posHighlightingEnabled: state.posHighlightingEnabled, + styleCheckSettings: state.isFocusMode ? FOCUS_MODE_STYLE_CHECK_SETTINGS : state.styleCheckSettings, + })), + ); + +export const useCalmUiSettings = () => + useAppStore( + useShallow((state): CalmUiSettings & { chromeTemporarilyVisible: boolean } => ({ + ...state.calmUiSettings, + chromeTemporarilyVisible: state.chromeTemporarilyVisible, + })), + ); + +export const useCalmUiActions = () => + useAppStore( + useShallow((state) => ({ + setCalmUiSettings: state.setCalmUiSettings, + toggleCalmUi: state.toggleCalmUi, + setCalmUiFocusMode: state.setCalmUiFocusMode, + setChromeTemporarilyVisible: state.setChromeTemporarilyVisible, + revealChromeTemporarily: state.revealChromeTemporarily, + })), + ); + +export type SidebarStateReturn = ReturnType; +export type ToolbarStateReturn = ReturnType; +export type EditorPresentationStateReturn = ReturnType; +export type WorkspacePanelSidebarStateReturn = ReturnType; +export type WorkspacePanelModeStateReturn = ReturnType; +export type TopBarsCollapsedReturn = ReturnType; +export type StatusBarCollapsedReturn = ReturnType; diff --git a/src/state/stores/app.ts b/src/state/stores/app.ts index e81db23..b3efd89 100644 --- a/src/state/stores/app.ts +++ b/src/state/stores/app.ts @@ -1,6 +1,6 @@ -import type { Tab } from "$types"; -import { create, type StateCreator } from "zustand"; -import { useShallow } from "zustand/react/shallow"; +import { logger } from "$logger"; +import { DEFAULT_OPTIONS } from "$pdf/constants"; +import { globalCaptureSet, runCmd } from "$ports"; import type { AppStore, EditorPresentationActions, @@ -10,8 +10,12 @@ import type { LayoutState, PdfExportActions, PdfExportState, + SearchActions, + SearchState, TabsActions, TabsState, + UiActions, + UiState, ViewModeActions, ViewModeState, WorkspaceDocumentsActions, @@ -21,7 +25,9 @@ import type { WorkspaceState, WriterToolsActions, WriterToolsState, -} from "../types"; +} from "$state/types"; +import type { GlobalCaptureSettings, Tab } from "$types"; +import { create, type StateCreator } from "zustand"; let nextTabId = 1; @@ -29,12 +35,25 @@ function generateTabId(): string { return `tab-${nextTabId++}`; } +const DEFAULT_GLOBAL_CAPTURE_SETTINGS: GlobalCaptureSettings = { + enabled: true, + shortcut: "CommandOrControl+Shift+Space", + paused: false, + defaultMode: "QuickNote", + targetLocationId: null, + inboxRelativeDir: "inbox", + appendTarget: null, + closeAfterSave: true, + showTrayIcon: true, + lastCaptureTarget: null, +}; + const getInitialLayoutChromeState = (): LayoutChromeState => ({ sidebarCollapsed: false, topBarsCollapsed: false, statusBarCollapsed: false, showSearch: false, - calmUiSettings: { enabled: true, autoHide: true, focusMode: true }, + calmUiSettings: { enabled: true, focusMode: true }, chromeTemporarilyVisible: false, }); @@ -50,7 +69,7 @@ const getInitialEditorPresentationState = (): EditorPresentationState => ({ const getInitialViewModeState = (): ViewModeState => ({ isSplitView: false, isFocusMode: false, - isPreviewVisible: true, + isPreviewVisible: false, focusModeSettings: { typewriterScrollingEnabled: true, dimmingMode: "sentence" }, }); @@ -81,6 +100,8 @@ const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => ({ selectedDocPath: undefined, documents: [], isLoadingDocuments: false, + refreshingLocationId: undefined, + sidebarRefreshReason: null, }); const getInitialWorkspaceState = (): WorkspaceState => ({ @@ -92,6 +113,20 @@ const getInitialTabsState = (): TabsState => ({ tabs: [], activeTabId: null }); const getInitialPdfExportState = (): PdfExportState => ({ isExportingPdf: false, pdfExportError: null }); +const getInitialSearchState = (): SearchState => ({ + searchQuery: "", + searchResults: [], + isSearching: false, + searchFilters: {}, +}); + +const getInitialUiState = (): UiState => ({ + layoutSettingsOpen: false, + pdfExportDialogOpen: false, + pdfExportOptions: DEFAULT_OPTIONS, + globalCaptureSettings: DEFAULT_GLOBAL_CAPTURE_SETTINGS, +}); + const createLayoutChromeSlice: StateCreator = (set) => ({ ...getInitialLayoutChromeState(), @@ -119,7 +154,6 @@ const createLayoutChromeSlice: StateCreator set((state) => ({ calmUiSettings: { ...state.calmUiSettings, autoHide: value } })), setCalmUiFocusMode: (value) => set((state) => ({ calmUiSettings: { ...state.calmUiSettings, focusMode: value } })), setChromeTemporarilyVisible: (value) => set({ chromeTemporarilyVisible: value }), revealChromeTemporarily: () => set({ chromeTemporarilyVisible: true }), @@ -157,6 +191,7 @@ const createViewModeSlice: StateCreator set({ isSplitView: false, isPreviewVisible: false }), setFocusMode: (value) => set({ isFocusMode: value }), toggleFocusMode: () => set((state) => ({ isFocusMode: !state.isFocusMode })), @@ -267,6 +302,8 @@ const createWorkspaceDocumentsSlice: StateCreator< setSelectedDocPath: (path) => set({ selectedDocPath: path }), setDocuments: (documents) => set({ documents }), setLoadingDocuments: (value) => set({ isLoadingDocuments: value }), + setSidebarRefreshState: (locationId, reason = null) => + set({ refreshingLocationId: locationId, sidebarRefreshReason: locationId === undefined ? null : reason }), }); const createTabsSlice: StateCreator = (set, get) => ({ @@ -366,6 +403,55 @@ const createPdfExportSlice: StateCreator set({ ...getInitialPdfExportState() }), }); +const createSearchSlice: StateCreator = (set) => ({ + ...getInitialSearchState(), + + setSearchQuery: (value) => set({ searchQuery: value }), + setSearchResults: (value) => set({ searchResults: value }), + setIsSearching: (value) => set({ isSearching: value }), + setSearchFilters: (value) => set({ searchFilters: value }), + resetSearch: () => set({ ...getInitialSearchState() }), +}); + +const createUiSlice: StateCreator = (set, get) => ({ + ...getInitialUiState(), + + setLayoutSettingsOpen: (value) => set({ layoutSettingsOpen: value }), + setPdfExportDialogOpen: (value) => set({ pdfExportDialogOpen: value }), + setPdfExportOptions: (value) => set({ pdfExportOptions: value }), + resetPdfExportOptions: () => set({ pdfExportOptions: DEFAULT_OPTIONS }), + setPdfPageSize: (value) => set((state) => ({ pdfExportOptions: { ...state.pdfExportOptions, pageSize: value } })), + setPdfOrientation: (value) => + set((state) => ({ pdfExportOptions: { ...state.pdfExportOptions, orientation: value } })), + setPdfFontSize: (value) => set((state) => ({ pdfExportOptions: { ...state.pdfExportOptions, fontSize: value } })), + setPdfMargin: (side, value) => + set((state) => ({ + pdfExportOptions: { + ...state.pdfExportOptions, + margins: { ...state.pdfExportOptions.margins, [side]: Number.isNaN(value) ? 0 : value }, + }, + })), + setPdfIncludeHeader: (value) => + set((state) => ({ pdfExportOptions: { ...state.pdfExportOptions, includeHeader: value } })), + setPdfIncludeFooter: (value) => + set((state) => ({ pdfExportOptions: { ...state.pdfExportOptions, includeFooter: value } })), + setGlobalCaptureSettings: (value) => set({ globalCaptureSettings: value }), + setQuickCaptureEnabled: async (enabled) => { + const previous = get().globalCaptureSettings; + if (previous.enabled === enabled) { + return; + } + + const next = { ...previous, enabled }; + set({ globalCaptureSettings: next }); + + await runCmd(globalCaptureSet(next, () => {}, (error) => { + logger.error("Failed to persist quick capture enabled state", error); + set({ globalCaptureSettings: previous }); + })); + }, +}); + export const useAppStore = create()((...params) => ({ ...createLayoutChromeSlice(...params), ...createEditorPresentationSlice(...params), @@ -375,180 +461,10 @@ export const useAppStore = create()((...params) => ({ ...createWorkspaceDocumentsSlice(...params), ...createTabsSlice(...params), ...createPdfExportSlice(...params), + ...createSearchSlice(...params), + ...createUiSlice(...params), })); -export const useLayoutChromeState = () => - useAppStore( - useShallow((state) => ({ - sidebarCollapsed: state.sidebarCollapsed, - topBarsCollapsed: state.topBarsCollapsed, - statusBarCollapsed: state.statusBarCollapsed, - showSearch: state.showSearch, - calmUiSettings: state.calmUiSettings, - chromeTemporarilyVisible: state.chromeTemporarilyVisible, - })), - ); - -export const useLayoutChromeActions = () => - useAppStore( - useShallow((state) => ({ - setSidebarCollapsed: state.setSidebarCollapsed, - toggleSidebarCollapsed: state.toggleSidebarCollapsed, - setTopBarsCollapsed: state.setTopBarsCollapsed, - toggleTabBarCollapsed: state.toggleTabBarCollapsed, - setStatusBarCollapsed: state.setStatusBarCollapsed, - toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, - setShowSearch: state.setShowSearch, - toggleShowSearch: state.toggleShowSearch, - setCalmUiSettings: state.setCalmUiSettings, - toggleCalmUi: state.toggleCalmUi, - setCalmUiAutoHide: state.setCalmUiAutoHide, - setCalmUiFocusMode: state.setCalmUiFocusMode, - setChromeTemporarilyVisible: state.setChromeTemporarilyVisible, - revealChromeTemporarily: state.revealChromeTemporarily, - })), - ); - -export const useEditorPresentationState = () => - useAppStore( - useShallow((state) => ({ - lineNumbersVisible: state.lineNumbersVisible, - textWrappingEnabled: state.textWrappingEnabled, - syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, - editorFontSize: state.editorFontSize, - editorFontFamily: state.editorFontFamily, - theme: state.theme, - })), - ); - -export const useEditorPresentationActions = () => - useAppStore( - useShallow((state) => ({ - setLineNumbersVisible: state.setLineNumbersVisible, - toggleLineNumbersVisible: state.toggleLineNumbersVisible, - setTextWrappingEnabled: state.setTextWrappingEnabled, - toggleTextWrappingEnabled: state.toggleTextWrappingEnabled, - setSyntaxHighlightingEnabled: state.setSyntaxHighlightingEnabled, - toggleSyntaxHighlightingEnabled: state.toggleSyntaxHighlightingEnabled, - setEditorFontSize: state.setEditorFontSize, - setEditorFontFamily: state.setEditorFontFamily, - })), - ); - -export const useViewModeState = () => - useAppStore( - useShallow((state) => ({ - isSplitView: state.isSplitView, - isFocusMode: state.isFocusMode, - isPreviewVisible: state.isPreviewVisible, - focusModeSettings: state.focusModeSettings, - })), - ); - -export const useViewModeActions = () => - useAppStore( - useShallow((state) => ({ - setSplitView: state.setSplitView, - toggleSplitView: state.toggleSplitView, - setFocusMode: state.setFocusMode, - toggleFocusMode: state.toggleFocusMode, - setFocusModeSettings: state.setFocusModeSettings, - setTypewriterScrollingEnabled: state.setTypewriterScrollingEnabled, - setFocusDimmingMode: state.setFocusDimmingMode, - toggleTypewriterScrolling: state.toggleTypewriterScrolling, - setPreviewVisible: state.setPreviewVisible, - togglePreviewVisible: state.togglePreviewVisible, - })), - ); - -export const useWriterToolsState = () => - useAppStore( - useShallow((state) => ({ - posHighlightingEnabled: state.posHighlightingEnabled, - styleCheckSettings: state.styleCheckSettings, - })), - ); - -export const useWriterToolsActions = () => - useAppStore( - useShallow((state) => ({ - setPosHighlightingEnabled: state.setPosHighlightingEnabled, - togglePosHighlighting: state.togglePosHighlighting, - setStyleCheckSettings: state.setStyleCheckSettings, - toggleStyleCheck: state.toggleStyleCheck, - setStyleCheckCategory: state.setStyleCheckCategory, - addCustomPattern: state.addCustomPattern, - removeCustomPattern: state.removeCustomPattern, - })), - ); - -export const useWorkspaceLocationsState = () => - useAppStore( - useShallow((state) => ({ - locations: state.locations, - isLoadingLocations: state.isLoadingLocations, - selectedLocationId: state.selectedLocationId, - sidebarFilter: state.sidebarFilter, - })), - ); - -export const useWorkspaceLocationsActions = () => - useAppStore( - useShallow((state) => ({ - setSidebarFilter: state.setSidebarFilter, - setLocations: state.setLocations, - setLoadingLocations: state.setLoadingLocations, - setSelectedLocation: state.setSelectedLocation, - addLocation: state.addLocation, - removeLocation: state.removeLocation, - })), - ); - -export const useWorkspaceDocumentsState = () => - useAppStore( - useShallow((state) => ({ - selectedDocPath: state.selectedDocPath, - documents: state.documents, - isLoadingDocuments: state.isLoadingDocuments, - })), - ); - -export const useWorkspaceDocumentsActions = () => - useAppStore( - useShallow((state) => ({ - setSelectedDocPath: state.setSelectedDocPath, - setDocuments: state.setDocuments, - setLoadingDocuments: state.setLoadingDocuments, - })), - ); - -export const useTabsState = () => - useAppStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }))); - -export const useTabsActions = () => - useAppStore( - useShallow((state) => ({ - openDocumentTab: state.openDocumentTab, - selectTab: state.selectTab, - closeTab: state.closeTab, - reorderTabs: state.reorderTabs, - markActiveTabModified: state.markActiveTabModified, - })), - ); - -export const usePdfExportState = () => - useAppStore(useShallow((state) => ({ isExportingPdf: state.isExportingPdf, pdfExportError: state.pdfExportError }))); - -export const usePdfExportActions = () => - useAppStore( - useShallow((state) => ({ - startPdfExport: state.startPdfExport, - finishPdfExport: state.finishPdfExport, - failPdfExport: state.failPdfExport, - resetPdfExport: state.resetPdfExport, - })), - ); - export function resetAppStore(): void { nextTabId = 1; @@ -557,5 +473,7 @@ export function resetAppStore(): void { ...getInitialWorkspaceState(), ...getInitialTabsState(), ...getInitialPdfExportState(), + ...getInitialSearchState(), + ...getInitialUiState(), }); } diff --git a/src/state/types.ts b/src/state/types.ts index d825db2..3ec2167 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -1,3 +1,4 @@ +import type { PdfExportOptions } from "$pdf/types"; import type { AppTheme, CalmUiSettings, @@ -6,14 +7,17 @@ import type { EditorFontFamily, FocusDimmingMode, FocusModeSettings, + GlobalCaptureSettings, LocationDescriptor, PatternCategory, + SearchHit, 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 = { sidebarCollapsed: boolean; @@ -53,7 +57,6 @@ export type LayoutChromeActions = { toggleShowSearch: () => void; setCalmUiSettings: (settings: CalmUiSettings) => void; toggleCalmUi: () => void; - setCalmUiAutoHide: (value: boolean) => void; setCalmUiFocusMode: (value: boolean) => void; setChromeTemporarilyVisible: (value: boolean) => void; revealChromeTemporarily: () => void; @@ -73,6 +76,7 @@ export type EditorPresentationActions = { export type ViewModeActions = { setSplitView: (value: boolean) => void; toggleSplitView: () => void; + setEditorOnlyMode: () => void; setFocusMode: (value: boolean) => void; toggleFocusMode: () => void; setFocusModeSettings: (settings: FocusModeSettings) => void; @@ -104,7 +108,15 @@ export type WorkspaceLocationsState = { sidebarFilter: string; }; -export type WorkspaceDocumentsState = { selectedDocPath?: string; documents: DocMeta[]; isLoadingDocuments: boolean }; +export type SidebarRefreshReason = "manual" | "save" | "external"; + +export type WorkspaceDocumentsState = { + selectedDocPath?: string; + documents: DocMeta[]; + isLoadingDocuments: boolean; + refreshingLocationId?: number; + sidebarRefreshReason: SidebarRefreshReason | null; +}; export type WorkspaceLocationsActions = { setSidebarFilter: (value: string) => void; @@ -119,6 +131,7 @@ export type WorkspaceDocumentsActions = { setSelectedDocPath: (path?: string) => void; setDocuments: (documents: DocMeta[]) => void; setLoadingDocuments: (value: boolean) => void; + setSidebarRefreshState: (locationId?: number, reason?: SidebarRefreshReason | null) => void; }; export type WorkspaceState = WorkspaceLocationsState & WorkspaceDocumentsState; @@ -144,6 +157,43 @@ export type PdfExportActions = { resetPdfExport: () => void; }; +export type SearchState = { + searchQuery: string; + searchResults: SearchHit[]; + isSearching: boolean; + searchFilters: SearchFilters; +}; + +export type SearchActions = { + setSearchQuery: (value: string) => void; + setSearchResults: (value: SearchHit[]) => void; + setIsSearching: (value: boolean) => void; + setSearchFilters: (value: SearchFilters) => void; + resetSearch: () => void; +}; + +export type UiState = { + layoutSettingsOpen: boolean; + pdfExportDialogOpen: boolean; + pdfExportOptions: PdfExportOptions; + globalCaptureSettings: GlobalCaptureSettings; +}; + +export type UiActions = { + setLayoutSettingsOpen: (value: boolean) => void; + setPdfExportDialogOpen: (value: boolean) => void; + setPdfExportOptions: (value: PdfExportOptions) => void; + resetPdfExportOptions: () => void; + setPdfPageSize: (value: PdfExportOptions["pageSize"]) => void; + setPdfOrientation: (value: PdfExportOptions["orientation"]) => void; + setPdfFontSize: (value: number) => void; + setPdfMargin: (side: "top" | "right" | "bottom" | "left", value: number) => void; + setPdfIncludeHeader: (value: boolean) => void; + setPdfIncludeFooter: (value: boolean) => void; + setGlobalCaptureSettings: (value: GlobalCaptureSettings) => void; + setQuickCaptureEnabled: (enabled: boolean) => Promise; +}; + export type AppStore = & LayoutState & LayoutActions @@ -152,7 +202,11 @@ export type AppStore = & TabsState & TabsActions & PdfExportState - & PdfExportActions; + & PdfExportActions + & SearchState + & SearchActions + & UiState + & UiActions; export type EditorPresentation = { theme: AppTheme; diff --git a/src/types.ts b/src/types.ts index 019da2b..24c5714 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,7 +99,7 @@ export type CaptureMode = "QuickNote" | "WritingSession" | "Append"; export type CaptureDocRef = { locationId: number; relPath: string }; -export type CalmUiSettings = { enabled: boolean; autoHide: boolean; focusMode: boolean }; +export type CalmUiSettings = { enabled: boolean; focusMode: boolean }; export type GlobalCaptureSettings = { enabled: boolean; diff --git a/src/usePorts.ts b/src/usePorts.ts index 4797e51..0b87712 100644 --- a/src/usePorts.ts +++ b/src/usePorts.ts @@ -1,15 +1,7 @@ -import type { BackendEvent, Cmd } from "$ports"; -import { - backendEvents, - locationAddViaDialog, - locationList, - locationRemove, - locationValidate, - runCmd, - SubscriptionManager, -} from "$ports"; -import { AppError, LocationDescriptor, LocationId } from "$types"; -import { useCallback, useEffect, useRef, useState } from "react"; +import type { Cmd } from "$ports"; +import type { AppError } from "$types"; +import { useCallback, useState } from "react"; +import { runCmd } from "./ports"; type UsePortsState = { data: T | null; error: AppError | null; loading: boolean }; @@ -19,7 +11,9 @@ export function usePorts(): UsePortsReturn { const [state, setState] = useState>({ data: null, error: null, loading: false }); const execute = useCallback(async (cmd: Cmd) => { - if (cmd.type === "None") return; + if (cmd.type === "None") { + return; + } setState((prev) => ({ ...prev, loading: true, error: null })); @@ -41,116 +35,3 @@ export function usePorts(): UsePortsReturn { return { ...state, execute, reset }; } - -interface UseBackendEventsOptions { - onLocationMissing?: (locationId: LocationId, path: string) => void; - onLocationChanged?: (locationId: LocationId, oldPath: string, newPath: string) => void; - onReconciliationComplete?: (checked: number, missing: LocationId[]) => void; -} - -export function useBackendEvents(options: UseBackendEventsOptions): void { - const optionsRef = useRef(options); - optionsRef.current = options; - - useEffect(() => { - const manager = new SubscriptionManager(); - let cleanupFn: (() => void) | undefined; - - const handleEvent = (event: BackendEvent) => { - switch (event.type) { - case "LocationMissing": { - optionsRef.current.onLocationMissing?.(event.location_id, event.path); - break; - } - case "LocationChanged": { - optionsRef.current.onLocationChanged?.(event.location_id, event.old_path, event.new_path); - break; - } - case "ReconciliationComplete": { - optionsRef.current.onReconciliationComplete?.(event.checked, event.missing); - break; - } - } - }; - - manager.subscribe(backendEvents(handleEvent)).then((cleanup) => { - cleanupFn = cleanup; - }); - - return () => { - cleanupFn?.(); - manager.cleanup(); - }; - }, []); -} - -interface UseLocationsReturn { - locations: LocationDescriptor[]; - loading: boolean; - error: AppError | null; - refresh: () => void; - addLocation: () => Promise; - removeLocation: (locationId: LocationId) => Promise; - validateLocations: () => Promise>; -} - -export function useLocations(): UseLocationsReturn { - const [locations, setLocations] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - setLoading(true); - setError(null); - - await runCmd(locationList((newLocations) => { - setLocations(newLocations); - setLoading(false); - }, (err) => { - setError(err); - setLoading(false); - })); - }, []); - - const addLocation = useCallback(async () => { - setLoading(true); - setError(null); - - await runCmd(locationAddViaDialog(() => { - refresh(); - }, (err) => { - setError(err); - setLoading(false); - })); - }, [refresh]); - - const removeLocation = useCallback(async (locationId: LocationId) => { - setLoading(true); - setError(null); - - await runCmd(locationRemove(locationId, (removed) => { - if (removed) { - refresh(); - } else { - setLoading(false); - } - }, (err) => { - setError(err); - setLoading(false); - })); - }, [refresh]); - - const validateLocations = useCallback( - async (): Promise> => - await new Promise((resolve, reject) => { - runCmd(locationValidate((missing) => resolve(missing), (err) => reject(err))); - }), - [], - ); - - useEffect(() => { - refresh(); - }, [refresh]); - - return { locations, loading, error, refresh, addLocation, removeLocation, validateLocations }; -} diff --git a/tsconfig.json b/tsconfig.json index acded2c..98d2cbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "noFallthroughCasesInSwitch": true, "baseUrl": "./src", "paths": { - "$ports": ["./ports.ts"], + "$ports": ["./ports/index.ts"], "$types": ["./types.ts"], "$logger": ["./logger.ts"], "$icons": ["./components/icons.tsx"], diff --git a/vite.config.ts b/vite.config.ts index a885283..efeaf17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ test, resolve: { alias: { - "$ports": resolveFromRoot("./src/ports.ts"), + "$ports": resolveFromRoot("./src/ports/index.ts"), "$types": resolveFromRoot("./src/types.ts"), "$logger": resolveFromRoot("./src/logger.ts"), "$icons": resolveFromRoot("./src/components/icons.tsx"),