From 311c07336451546208a0d8f36056df01b674bb8e Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 26 Feb 2026 15:08:40 -0600 Subject: [PATCH] refactor: break up zustand monolith --- src/App.css | 77 ++- src/App.tsx | 40 +- src/{routes/AppRouter.tsx => Router.tsx} | 2 +- src/__tests__/Editor.test.tsx | 28 +- src/{test => __tests__}/setup.ts | 0 src/__tests__/useBackendEvents.test.tsx | 2 +- src/components/Editor.tsx | 228 +++++--- src/{ => editor}/data/style-dictionaries.json | 0 src/editor/style-check.ts | 2 +- src/{ => editor}/themes/oxocarbon-dark.ts | 0 src/{ => editor}/themes/oxocarbon-light.ts | 0 src/hooks/app/useSettingsSync.ts | 9 +- .../controllers/useAppChromeController.ts | 35 ++ .../controllers/useWorkspaceController.ts | 24 +- ...oller.ts => useWorkspaceViewController.ts} | 39 +- src/main.tsx | 6 +- src/state/selectors.ts | 107 ++-- src/state/stores/app.ts | 525 ++---------------- src/state/stores/layout.ts | 178 ++++++ src/state/stores/pdf-export.ts | 19 + src/state/stores/search.ts | 25 + src/state/stores/tabs.ts | 134 +++++ src/state/stores/ui.ts | 71 +++ src/state/stores/workspace.ts | 70 +++ src/types.ts | 2 + tsconfig.json | 2 - vite.config.ts | 6 +- 27 files changed, 920 insertions(+), 711 deletions(-) rename src/{routes/AppRouter.tsx => Router.tsx} (97%) rename src/{test => __tests__}/setup.ts (100%) rename src/{ => editor}/data/style-dictionaries.json (100%) rename src/{ => editor}/themes/oxocarbon-dark.ts (100%) rename src/{ => editor}/themes/oxocarbon-light.ts (100%) create mode 100644 src/hooks/controllers/useAppChromeController.ts rename src/hooks/controllers/{useAppController.ts => useWorkspaceViewController.ts} (86%) create mode 100644 src/state/stores/layout.ts create mode 100644 src/state/stores/pdf-export.ts create mode 100644 src/state/stores/search.ts create mode 100644 src/state/stores/tabs.ts create mode 100644 src/state/stores/ui.ts create mode 100644 src/state/stores/workspace.ts diff --git a/src/App.css b/src/App.css index f8f19a1..f69dd70 100644 --- a/src/App.css +++ b/src/App.css @@ -320,6 +320,29 @@ --spacing-sidebar-collapsed: 48px; --spacing-header: 48px; --spacing-tab: 40px; + --spacing-preview-content-max-width: 46rem; + --spacing-preview-block-gap: 1em; + --spacing-preview-heading-top: 1.5em; + --spacing-preview-heading-bottom: 0.5em; + --spacing-preview-list-indent: 2em; + --spacing-preview-list-item-gap: 0.25em; + --spacing-preview-hr-gap: 1.5em; + --spacing-preview-inline-code-x: 0.4em; + --spacing-preview-inline-code-y: 0.2em; + --spacing-preview-code-block: 1em; + --spacing-preview-table-cell: 0.5em; + --spacing-preview-checkbox-gap: 0.5em; + --font-size-preview-body: 1rem; + --font-size-preview-h1: 2em; + --font-size-preview-h2: 1.5em; + --font-size-preview-h3: 1.25em; + --font-size-preview-inline-code: 0.9em; + --line-height-preview-body: 1.7; + --line-height-preview-heading: 1.25; + --border-width-preview-heading: 1px; + --border-width-preview-blockquote: 4px; + --weight-preview-heading: 600; + --weight-preview-table-heading: 600; --radius-none: 0; --radius-sm: 2px; @@ -485,38 +508,38 @@ body, /* Markdown Preview Styles */ .preview-content { - max-width: 46rem; + max-width: var(--spacing-preview-content-max-width); margin: 0 auto; - line-height: 1.7; - font-size: 1rem; + line-height: var(--line-height-preview-body); + font-size: var(--font-size-preview-body); } .preview-content :is(h1, h2, h3, h4, h5, h6) { color: var(--color-text-primary); - margin-top: 1.5em; - margin-bottom: 0.5em; - font-weight: 600; - line-height: 1.25; + margin-top: var(--spacing-preview-heading-top); + margin-bottom: var(--spacing-preview-heading-bottom); + font-weight: var(--weight-preview-heading); + line-height: var(--line-height-preview-heading); } .preview-content h1 { - font-size: 2em; - border-bottom: 1px solid var(--color-border-subtle); + font-size: var(--font-size-preview-h1); + border-bottom: var(--border-width-preview-heading) solid var(--color-border-subtle); padding-bottom: 0.3em; } .preview-content h2 { - font-size: 1.5em; - border-bottom: 1px solid var(--color-border-subtle); + font-size: var(--font-size-preview-h2); + border-bottom: var(--border-width-preview-heading) solid var(--color-border-subtle); padding-bottom: 0.3em; } .preview-content h3 { - font-size: 1.25em; + font-size: var(--font-size-preview-h3); } .preview-content p { - margin: 0 0 1em 0; + margin: 0 0 var(--spacing-preview-block-gap) 0; } .preview-content a { @@ -530,18 +553,18 @@ body, .preview-content code { background: var(--color-layer-01); - padding: 0.2em 0.4em; + padding: var(--spacing-preview-inline-code-y) var(--spacing-preview-inline-code-x); border-radius: var(--radius-md); font-family: var(--font-mono); - font-size: 0.9em; + font-size: var(--font-size-preview-inline-code); } .preview-content pre { background: var(--color-layer-01); - padding: 1em; + padding: var(--spacing-preview-code-block); border-radius: var(--radius-lg); overflow-x: auto; - margin: 0 0 1em 0; + margin: 0 0 var(--spacing-preview-block-gap) 0; } .preview-content pre code { @@ -550,42 +573,42 @@ body, } .preview-content blockquote { - border-left: 4px solid var(--color-border-subtle); - margin: 0 0 1em 0; + border-left: var(--border-width-preview-blockquote) solid var(--color-border-subtle); + margin: 0 0 var(--spacing-preview-block-gap) 0; padding: 0 1em; color: var(--color-text-secondary); } .preview-content :is(ul, ol) { - margin: 0 0 1em 0; - padding-left: 2em; + margin: 0 0 var(--spacing-preview-block-gap) 0; + padding-left: var(--spacing-preview-list-indent); } .preview-content li { - margin: 0.25em 0; + margin: var(--spacing-preview-list-item-gap) 0; } .preview-content table { width: 100%; border-collapse: collapse; - margin: 0 0 1em 0; + margin: 0 0 var(--spacing-preview-block-gap) 0; } .preview-content :is(th, td) { border: 1px solid var(--color-border-subtle); - padding: 0.5em; + padding: var(--spacing-preview-table-cell); text-align: left; } .preview-content th { background: var(--color-layer-01); - font-weight: 600; + font-weight: var(--weight-preview-table-heading); } .preview-content hr { border: none; border-top: 1px solid var(--color-border-subtle); - margin: 1.5em 0; + margin: var(--spacing-preview-hr-gap) 0; } .preview-content img { @@ -598,5 +621,5 @@ body, } .preview-content input[type="checkbox"] { - margin-right: 0.5em; + margin-right: var(--spacing-preview-checkbox-gap); } diff --git a/src/App.tsx b/src/App.tsx index c05b3da..06d03c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,8 @@ 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 { useAppController } from "./hooks/controllers/useAppController"; +import { useAppChromeController } from "./hooks/controllers/useAppChromeController"; +import { useWorkspaceViewController } from "./hooks/controllers/useWorkspaceViewController"; const ShowButton = ({ clickHandler, title, label }: { clickHandler: () => void; title: string; label: string }) => ( ); -function App() { - const { - theme, - isFocusMode, - isSidebarCollapsed, - showToggleControls, - workspacePanelProps, - focusModePanelProps, - handleShowSidebar, - handleExportPdf, - } = useAppController(); +const AppContent = ({ isFocusMode }: { isFocusMode: boolean }) => { + const { workspacePanelProps, focusModePanelProps, handleExportPdf } = useWorkspaceViewController(); if (isFocusMode) { return ; } + return ( + <> + + + + + + + ); +}; + +function App() { + const { theme, isFocusMode, isSidebarCollapsed, showToggleControls, handleShowSidebar } = useAppChromeController(); + return (
- {showToggleControls && ( + {!isFocusMode && showToggleControls && (
{isSidebarCollapsed && ( @@ -45,12 +51,8 @@ function App() {
)} - - - - - - + {isFocusMode ? null : } +
); } diff --git a/src/routes/AppRouter.tsx b/src/Router.tsx similarity index 97% rename from src/routes/AppRouter.tsx rename to src/Router.tsx index f922d99..79885ad 100644 --- a/src/routes/AppRouter.tsx +++ b/src/Router.tsx @@ -1,7 +1,7 @@ import { QuickCaptureApp } from "$components/capture"; import { Route, Router, Switch } from "wouter"; import { useHashLocation } from "wouter/use-hash-location"; -import App from "../App"; +import App from "./App"; const NotFoundRoute = () => (
diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx index 5c703f0..8f22936 100644 --- a/src/__tests__/Editor.test.tsx +++ b/src/__tests__/Editor.test.tsx @@ -170,7 +170,7 @@ describe(Editor, () => { expect(container.querySelector(".cm-editor")).toBe(firstEditorRoot); }); - it("recreates the editor view when presentation props change", () => { + it("keeps the editor view instance when presentation props change", () => { const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); @@ -179,11 +179,11 @@ describe(Editor, () => { expect(screen.getByTestId("editor-container")).toHaveAttribute("data-theme", "light"); expect(secondEditorRoot).toBeInTheDocument(); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); }); - it("recreates the editor view when line number visibility changes", () => { + it("keeps the editor view instance when line number visibility changes", () => { const { container, rerender } = render( , ); @@ -192,12 +192,12 @@ describe(Editor, () => { const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); expect(container.querySelector(".cm-lineNumbers")).not.toBeInTheDocument(); expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); }); - it("recreates the editor view when text wrapping changes", () => { + it("keeps the editor view instance when text wrapping changes", () => { const { container, rerender } = render( , ); @@ -206,12 +206,12 @@ describe(Editor, () => { const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); }); - it("recreates the editor view when syntax highlighting mode changes", () => { + it("keeps the editor view instance when syntax highlighting mode changes", () => { const { container, rerender } = render( , ); @@ -220,7 +220,7 @@ describe(Editor, () => { const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); expect(container.querySelector(".cm-content")).toHaveTextContent("# Heading"); }); @@ -250,22 +250,22 @@ describe(Editor, () => { expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); - it("should recreate editor when typewriterScrollingEnabled changes", () => { + it("should keep editor when typewriterScrollingEnabled changes", () => { const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); }); - it("should recreate editor when focusDimmingMode changes", () => { + it("should keep editor when focusDimmingMode changes", () => { const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); }); it("should apply POS highlighting when enabled", () => { @@ -278,13 +278,13 @@ describe(Editor, () => { expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); - it("should recreate editor when posHighlightingEnabled changes", () => { + it("should keep editor when posHighlightingEnabled changes", () => { const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); - expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(secondEditorRoot).toBe(firstEditorRoot); }); }); }); diff --git a/src/test/setup.ts b/src/__tests__/setup.ts similarity index 100% rename from src/test/setup.ts rename to src/__tests__/setup.ts diff --git a/src/__tests__/useBackendEvents.test.tsx b/src/__tests__/useBackendEvents.test.tsx index fd25973..1e3a886 100644 --- a/src/__tests__/useBackendEvents.test.tsx +++ b/src/__tests__/useBackendEvents.test.tsx @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useBackendEvents } from "../hooks/useBackendEvents"; -import { emitBackendEvent } from "../test/setup"; +import { emitBackendEvent } from "./setup"; describe(useBackendEvents, () => { beforeEach(() => { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index b9783bd..3484e90 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,14 +1,14 @@ import { focusDimming, focusDimmingTheme } from "$editor/focus-dimming"; import { posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting"; import { styleCheck, styleCheckTheme, type StyleMatch } from "$editor/style-check"; +import { oxocarbonDark } from "$editor/themes/oxocarbon-dark"; +import { oxocarbonLight } from "$editor/themes/oxocarbon-light"; import { typewriterScroll } from "$editor/typewriter-scroll"; import { useEditorPresentationState } from "$state/selectors"; -import { oxocarbonDark } from "$themes/oxocarbon-dark"; -import { oxocarbonLight } from "$themes/oxocarbon-light"; import type { AppTheme, EditorFontFamily, FocusDimmingMode, StyleCheckSettings } from "$types"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { EditorState as CMEditorState } from "@codemirror/state"; +import { Compartment, EditorState as CMEditorState, type Extension } from "@codemirror/state"; import { EditorView, keymap, lineNumbers } from "@codemirror/view"; import type { ViewUpdate } from "@codemirror/view"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -52,19 +52,37 @@ type EditorCallbacks = Pick< type CreateEditorStateOptions = { doc: string; + presentation: PresentationSnapshot; + placeholder?: string; + updateListener: ReturnType; + onSave: () => void; + onStyleMatchesChange: (matches: StyleMatch[]) => void; + compartments: EditorCompartments; +}; + +type PresentationSnapshot = { theme: EditorTheme; disabled: boolean; showLineNumbers: boolean; textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; - placeholder?: string; - updateListener: ReturnType; - onSave: () => void; typewriterScrollingEnabled: boolean; focusDimmingMode: FocusDimmingMode; posHighlightingEnabled: boolean; styleCheckSettings: StyleCheckSettings; - onStyleMatchesChange?: (matches: StyleMatch[]) => void; +}; + +type EditorCompartments = { + theme: Compartment; + editable: Compartment; + lineNumbers: Compartment; + wrapping: Compartment; + syntax: Compartment; + placeholder: Compartment; + typewriter: Compartment; + focusDimming: Compartment; + posHighlighting: Compartment; + styleCheck: Compartment; }; const EDITOR_FONT_FAMILY_MAP: Record = { @@ -79,26 +97,62 @@ const EDITOR_FONT_FAMILY_MAP: Record = { "Monaspace Xenon": "\"Writer Monaspace Xenon\", \"Writer IBM Plex Mono\", monospace", }; -function createEditorState( - { - doc, - theme, - disabled, - showLineNumbers, - textWrappingEnabled, - syntaxHighlightingEnabled, - placeholder, - updateListener, - onSave, - typewriterScrollingEnabled, - focusDimmingMode, - posHighlightingEnabled, - styleCheckSettings, - onStyleMatchesChange, - }: CreateEditorStateOptions, -): CMEditorState { +function getThemeExtension(theme: EditorTheme): Extension { const themeExtension = theme === "dark" ? oxocarbonDark : oxocarbonLight; + return themeExtension; +} + +function getSyntaxExtension(enabled: boolean): Extension { + if (!enabled) { + return []; + } + + return markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true }); +} + +function getFocusDimmingExtension(mode: FocusDimmingMode): Extension { + if (mode === "off") { + return []; + } + return [focusDimming(mode), focusDimmingTheme]; +} + +function getStyleCheckExtension( + settings: StyleCheckSettings, + onStyleMatchesChange: (matches: StyleMatch[]) => void, +): Extension { + if (!settings.enabled) { + return []; + } + + return [ + styleCheck({ + enabled: settings.enabled, + categories: settings.categories, + customPatterns: settings.customPatterns.map((p) => ({ + text: p.text, + category: p.category, + replacement: p.replacement, + })), + onMatchesChange: onStyleMatchesChange, + }), + styleCheckTheme, + ]; +} + +function getPlaceholderExtension(placeholder?: string): Extension { + if (!placeholder) { + return []; + } + + return EditorView.theme({ ".cm-placeholder": { color: "#888" } }); +} + +function createEditorState( + { doc, presentation, placeholder, updateListener, onSave, onStyleMatchesChange, compartments }: + CreateEditorStateOptions, +): CMEditorState { const saveKeymap = keymap.of([{ key: "Mod-s", preventDefault: true, @@ -108,39 +162,25 @@ function createEditorState( }, }]); - const styleCheckExtensions = styleCheckSettings.enabled - ? [ - styleCheck({ - enabled: styleCheckSettings.enabled, - categories: styleCheckSettings.categories, - customPatterns: styleCheckSettings.customPatterns.map((p) => ({ - text: p.text, - category: p.category, - replacement: p.replacement, - })), - onMatchesChange: onStyleMatchesChange, - }), - styleCheckTheme, - ] - : []; - return CMEditorState.create({ doc, extensions: [ - ...(showLineNumbers ? [lineNumbers()] : []), - ...(textWrappingEnabled ? [EditorView.lineWrapping] : []), history(), - ...(syntaxHighlightingEnabled ? [markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true })] : []), - themeExtension, updateListener, keymap.of([...defaultKeymap, ...historyKeymap]), saveKeymap, - EditorView.editable.of(!disabled), - placeholder ? EditorView.theme({ ".cm-placeholder": { color: "#888" } }) : [], - ...(typewriterScrollingEnabled ? [typewriterScroll()] : []), - ...(focusDimmingMode === "off" ? [] : [focusDimming(focusDimmingMode), focusDimmingTheme]), - ...(posHighlightingEnabled ? [posHighlighting(), posHighlightingTheme] : []), - ...styleCheckExtensions, + compartments.theme.of(getThemeExtension(presentation.theme)), + compartments.editable.of(EditorView.editable.of(!presentation.disabled)), + compartments.lineNumbers.of(presentation.showLineNumbers ? lineNumbers() : []), + compartments.wrapping.of(presentation.textWrappingEnabled ? EditorView.lineWrapping : []), + compartments.syntax.of(getSyntaxExtension(presentation.syntaxHighlightingEnabled)), + compartments.placeholder.of(getPlaceholderExtension(placeholder)), + compartments.typewriter.of(presentation.typewriterScrollingEnabled ? typewriterScroll() : []), + compartments.focusDimming.of(getFocusDimmingExtension(presentation.focusDimmingMode)), + compartments.posHighlighting.of( + presentation.posHighlightingEnabled ? [posHighlighting(), posHighlightingTheme] : [], + ), + compartments.styleCheck.of(getStyleCheckExtension(presentation.styleCheckSettings, onStyleMatchesChange)), ], }); } @@ -215,6 +255,18 @@ export function Editor( posHighlightingEnabled, styleCheckSettings, }); + const compartmentsRef = useRef({ + theme: new Compartment(), + editable: new Compartment(), + lineNumbers: new Compartment(), + wrapping: new Compartment(), + syntax: new Compartment(), + placeholder: new Compartment(), + typewriter: new Compartment(), + focusDimming: new Compartment(), + posHighlighting: new Compartment(), + styleCheck: new Compartment(), + }); const [isReady, setIsReady] = useState(false); useEffect(() => { @@ -270,19 +322,12 @@ export function Editor( const currentPresentation = presentationRef.current; const state = createEditorState({ doc, - theme: currentPresentation.theme, - disabled: currentPresentation.disabled, - showLineNumbers: currentPresentation.showLineNumbers, - textWrappingEnabled: currentPresentation.textWrappingEnabled, - syntaxHighlightingEnabled: currentPresentation.syntaxHighlightingEnabled, + presentation: currentPresentation, placeholder: currentPresentation.placeholder, updateListener: createUpdateListener(), onSave: () => callbacksRef.current.onSave?.(), - onStyleMatchesChange: callbacksRef.current.onStyleMatchesChange, - typewriterScrollingEnabled: currentPresentation.typewriterScrollingEnabled, - focusDimmingMode: currentPresentation.focusDimmingMode, - posHighlightingEnabled: currentPresentation.posHighlightingEnabled, - styleCheckSettings: currentPresentation.styleCheckSettings, + onStyleMatchesChange: (matches) => callbacksRef.current.onStyleMatchesChange?.(matches), + compartments: compartmentsRef.current, }); const view = new EditorView({ state, parent: containerRef.current }); @@ -360,21 +405,60 @@ export function Editor( if (!view) { return; } + const effects = []; + const compartments = compartmentsRef.current; - const currentText = view.state.doc.toString(); - const currentSelection = view.state.selection; - - view.destroy(); - - const nextView = createView(currentText, currentSelection); - if (!nextView) { - viewRef.current = null; - return; + if (previousPresentation.theme !== theme) { + effects.push(compartments.theme.reconfigure(getThemeExtension(theme))); + } + if (previousPresentation.disabled !== disabled) { + effects.push(compartments.editable.reconfigure(EditorView.editable.of(!disabled))); + } + if (previousPresentation.showLineNumbers !== showLineNumbers) { + effects.push(compartments.lineNumbers.reconfigure(showLineNumbers ? lineNumbers() : [])); + } + if (previousPresentation.textWrappingEnabled !== textWrappingEnabled) { + effects.push(compartments.wrapping.reconfigure(textWrappingEnabled ? EditorView.lineWrapping : [])); + } + if (previousPresentation.syntaxHighlightingEnabled !== syntaxHighlightingEnabled) { + effects.push(compartments.syntax.reconfigure(getSyntaxExtension(syntaxHighlightingEnabled))); + } + if (previousPresentation.placeholder !== placeholder) { + effects.push(compartments.placeholder.reconfigure(getPlaceholderExtension(placeholder))); + } + if (previousPresentation.typewriterScrollingEnabled !== typewriterScrollingEnabled) { + effects.push(compartments.typewriter.reconfigure(typewriterScrollingEnabled ? typewriterScroll() : [])); + } + if (previousPresentation.focusDimmingMode !== focusDimmingMode) { + effects.push(compartments.focusDimming.reconfigure(getFocusDimmingExtension(focusDimmingMode))); + } + if (previousPresentation.posHighlightingEnabled !== posHighlightingEnabled) { + effects.push( + compartments.posHighlighting.reconfigure( + posHighlightingEnabled ? [posHighlighting(), posHighlightingTheme] : [], + ), + ); + } + if ( + previousPresentation.styleCheckSettings.enabled !== styleCheckSettings.enabled + || previousPresentation.styleCheckSettings.categories.filler !== styleCheckSettings.categories.filler + || previousPresentation.styleCheckSettings.categories.redundancy !== styleCheckSettings.categories.redundancy + || previousPresentation.styleCheckSettings.categories.cliche !== styleCheckSettings.categories.cliche + || !areCustomPatternsEqual( + previousPresentation.styleCheckSettings.customPatterns, + styleCheckSettings.customPatterns, + ) + ) { + effects.push( + compartments.styleCheck.reconfigure(getStyleCheckExtension(styleCheckSettings, (matches) => + callbacksRef.current.onStyleMatchesChange?.(matches))), + ); } - viewRef.current = nextView; + if (effects.length > 0) { + view.dispatch({ effects }); + } }, [ - createView, disabled, placeholder, showLineNumbers, diff --git a/src/data/style-dictionaries.json b/src/editor/data/style-dictionaries.json similarity index 100% rename from src/data/style-dictionaries.json rename to src/editor/data/style-dictionaries.json diff --git a/src/editor/style-check.ts b/src/editor/style-check.ts index 719ba7c..d243fb5 100644 --- a/src/editor/style-check.ts +++ b/src/editor/style-check.ts @@ -14,11 +14,11 @@ * - Clichés: https://github.com/dundalek/no-cliches (MIT) */ -import styleDictionaries from "$data/style-dictionaries.json"; import { PatternCategory } from "$types"; import { RangeSetBuilder } from "@codemirror/state"; import type { Extension } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import styleDictionaries from "./data/style-dictionaries.json"; import type { Pattern } from "./pattern-matcher"; import { PatternMatcher } from "./pattern-matcher"; diff --git a/src/themes/oxocarbon-dark.ts b/src/editor/themes/oxocarbon-dark.ts similarity index 100% rename from src/themes/oxocarbon-dark.ts rename to src/editor/themes/oxocarbon-dark.ts diff --git a/src/themes/oxocarbon-light.ts b/src/editor/themes/oxocarbon-light.ts similarity index 100% rename from src/themes/oxocarbon-light.ts rename to src/editor/themes/oxocarbon-light.ts diff --git a/src/hooks/app/useSettingsSync.ts b/src/hooks/app/useSettingsSync.ts index bb70863..3ef3cf4 100644 --- a/src/hooks/app/useSettingsSync.ts +++ b/src/hooks/app/useSettingsSync.ts @@ -7,7 +7,8 @@ import { useViewModeState, useWriterToolsState, } from "$state/selectors"; -import { useAppStore } from "$state/stores/app"; +import { useLayoutStore } from "$state/stores/layout"; +import { useUiStore } from "$state/stores/ui"; import { useEffect, useState } from "react"; export function useSettingsSync(): void { @@ -25,7 +26,7 @@ export function useSettingsSync(): void { return; } - const state = useAppStore.getState(); + const state = useLayoutStore.getState(); state.setSidebarCollapsed(settings.sidebar_collapsed); state.setTopBarsCollapsed(settings.top_bars_collapsed); state.setStatusBarCollapsed(settings.status_bar_collapsed); @@ -48,7 +49,7 @@ export function useSettingsSync(): void { return; } - useAppStore.getState().setStyleCheckSettings({ + useLayoutStore.getState().setStyleCheckSettings({ enabled: settings.enabled, categories: settings.categories, customPatterns: settings.custom_patterns, @@ -60,7 +61,7 @@ export function useSettingsSync(): void { return; } - useAppStore.getState().setGlobalCaptureSettings(settings); + useUiStore.getState().setGlobalCaptureSettings(settings); }, (error) => { logger.error("Failed to load global capture settings", error); })); diff --git a/src/hooks/controllers/useAppChromeController.ts b/src/hooks/controllers/useAppChromeController.ts new file mode 100644 index 0000000..3926fac --- /dev/null +++ b/src/hooks/controllers/useAppChromeController.ts @@ -0,0 +1,35 @@ +import { + useEditorPresentationStateRaw, + useLayoutChromeActions, + useLayoutChromeState, + useViewModeState, +} from "$state/selectors"; +import type { AppTheme } from "$types"; +import { useCallback } from "react"; + +export type AppChromeController = { + theme: AppTheme; + isFocusMode: boolean; + isSidebarCollapsed: boolean; + showToggleControls: boolean; + handleShowSidebar: () => void; +}; + +export function useAppChromeController(): AppChromeController { + const layoutChrome = useLayoutChromeState(); + const editorPresentation = useEditorPresentationStateRaw(); + const { setSidebarCollapsed } = useLayoutChromeActions(); + const { isFocusMode } = useViewModeState(); + + const handleShowSidebar = useCallback(() => { + setSidebarCollapsed(false); + }, [setSidebarCollapsed]); + + return { + theme: editorPresentation.theme, + isFocusMode, + isSidebarCollapsed: layoutChrome.sidebarCollapsed, + showToggleControls: layoutChrome.sidebarCollapsed, + handleShowSidebar, + }; +} diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index cd52cca..c33153d 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -8,7 +8,8 @@ import { useWorkspaceLocationsActions, useWorkspaceLocationsState, } from "$state/selectors"; -import { useAppStore } from "$state/stores/app"; +import { useTabsStore } from "$state/stores/tabs"; +import { useWorkspaceStore } from "$state/stores/workspace"; import type { SidebarRefreshReason } from "$state/types"; import type { DocMeta, DocRef } from "$types"; import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; @@ -74,7 +75,7 @@ export function useWorkspaceController() { }, [removeLocation]); const handleSelectDocument = useCallback((locationId: number, path: string) => { - const docTitle = useAppStore.getState().documents.find((doc) => + const docTitle = useWorkspaceStore.getState().documents.find((doc) => doc.location_id === locationId && doc.rel_path === path )?.title; @@ -93,15 +94,16 @@ export function useWorkspaceController() { }, [openDocumentTab]); const handleCreateNewDocument = useCallback((locationId?: number) => { - const state = useAppStore.getState(); - const targetLocationId = locationId ?? state.selectedLocationId ?? state.locations[0]?.id; + const workspaceState = useWorkspaceStore.getState(); + const tabsState = useTabsStore.getState(); + const targetLocationId = locationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id; if (!targetLocationId) { logger.warn("Cannot create draft without a selected location."); return null; } - const relPath = buildDraftRelPath(targetLocationId, state.documents, state.tabs); + const relPath = buildDraftRelPath(targetLocationId, workspaceState.documents, tabsState.tabs); const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; openDocumentTab(docRef, getDraftTitle(relPath)); return docRef; @@ -110,17 +112,17 @@ export function useWorkspaceController() { 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; + const workspaceState = useWorkspaceStore.getState(); + const targetLocationId = locationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id; - if (!targetLocationId || state.selectedLocationId !== targetLocationId) { + if (!targetLocationId || workspaceState.selectedLocationId !== targetLocationId) { return; } setSidebarRefreshState(targetLocationId, source); runCmd(docList(targetLocationId, (nextDocuments) => { - const latestState = useAppStore.getState(); + const latestState = useWorkspaceStore.getState(); if (latestState.selectedLocationId !== targetLocationId) { if (latestState.refreshingLocationId === targetLocationId) { latestState.setSidebarRefreshState(undefined, null); @@ -128,8 +130,6 @@ export function useWorkspaceController() { 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 }); @@ -153,7 +153,7 @@ export function useWorkspaceController() { } logger.error("Failed to refresh sidebar documents", { locationId: targetLocationId, error }); - const latestState = useAppStore.getState(); + const latestState = useWorkspaceStore.getState(); if (latestState.refreshingLocationId === targetLocationId) { latestState.setSidebarRefreshState(undefined, null); } diff --git a/src/hooks/controllers/useAppController.ts b/src/hooks/controllers/useWorkspaceViewController.ts similarity index 86% rename from src/hooks/controllers/useAppController.ts rename to src/hooks/controllers/useWorkspaceViewController.ts index b4f42b7..d9301ef 100644 --- a/src/hooks/controllers/useAppController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -12,30 +12,19 @@ import { usePdfExport, usePdfExportUI } from "$hooks/usePdfExport"; import { usePreview } from "$hooks/usePreview"; import { useWorkspaceSync } from "$hooks/useWorkspaceSync"; import type { PdfExportOptions } from "$pdf/types"; -import { - useCalmUiSettings, - useEditorPresentationStateRaw, - useLayoutChromeActions, - useLayoutChromeState, - useViewModeState, -} from "$state/selectors"; -import type { AppTheme, DocRef } from "$types"; -import { useCallback, useMemo } from "react"; +import { useCalmUiSettings, useEditorPresentationStateRaw } from "$state/selectors"; +import type { DocRef, Maybe } from "$types"; +import { useMemo } from "react"; export type FocusModePanelProps = { editor: WorkspaceEditorProps; statusBar: StatusBarProps }; -export type AppController = { - theme: AppTheme; - isFocusMode: boolean; - isSidebarCollapsed: boolean; - showToggleControls: boolean; +export type WorkspaceViewController = { workspacePanelProps: WorkspacePanelProps; focusModePanelProps: FocusModePanelProps; - handleShowSidebar: () => void; handleExportPdf: (options: PdfExportOptions) => Promise; }; -function isSameDocRef(left: DocRef | null | undefined, right: DocRef | null | undefined): boolean { +function isSameDocRef(left: Maybe, right: Maybe): boolean { if (!left || !right) { return false; } @@ -43,7 +32,7 @@ function isSameDocRef(left: DocRef | null | undefined, right: DocRef | null | un return left.location_id === right.location_id && left.rel_path === right.rel_path; } -export function useAppController(): AppController { +export function useWorkspaceViewController(): WorkspaceViewController { const { model: editorModel, dispatch: editorDispatch, openDoc } = useEditor(); const { model: previewModel, render: renderPreview, syncLine: syncPreviewLine, setDoc: setPreviewDoc } = usePreview(); const exportPdf = usePdfExport(); @@ -51,11 +40,8 @@ export function useAppController(): AppController { useWorkspaceSync(); useLayoutHotkeys(); - const layoutChrome = useLayoutChromeState(); const calmUiSettings = useCalmUiSettings(); - const { setSidebarCollapsed } = useLayoutChromeActions(); const editorPresentation = useEditorPresentationStateRaw(); - const { isFocusMode } = useViewModeState(); const { locations, documents, @@ -132,8 +118,6 @@ export function useAppController(): AppController { return activeDoc ?? null; }, [activeTab, documents]); - const handleShowSidebar = useCallback(() => setSidebarCollapsed(false), [setSidebarCollapsed]); - const activeDocRef = activeTab?.docRef ?? null; useDocumentSessionEffects({ @@ -248,14 +232,5 @@ export function useAppController(): AppController { [toolbarProps, editorProps, previewProps, statusBarProps, calmUiEffectiveVisibility], ); - return { - theme: editorPresentation.theme, - isFocusMode, - isSidebarCollapsed: layoutChrome.sidebarCollapsed, - showToggleControls: layoutChrome.sidebarCollapsed, - workspacePanelProps, - focusModePanelProps, - handleShowSidebar, - handleExportPdf, - }; + return { workspacePanelProps, focusModePanelProps, handleExportPdf }; } diff --git a/src/main.tsx b/src/main.tsx index 46c3c5d..9de5984 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import { logger } from "$logger"; import { getCurrentWindow } from "@tauri-apps/api/window"; import React from "react"; import ReactDOM from "react-dom/client"; -import { applyInitialRoute, AppRouter } from "./routes/AppRouter"; +import { applyInitialRoute, AppRouter } from "./Router"; import "@fontsource-variable/ibm-plex-sans"; import "./App.css"; @@ -27,7 +27,9 @@ globalThis.addEventListener("unhandledrejection", (event) => { logger.error("Unhandled promise rejection", { reason: String(event.reason) }); }); -// Detect window label and render appropriate app +/** + * Detects window label and render appropriate app + */ const windowLabel = getCurrentWindow().label; logger.info(`Rendering app for window: ${windowLabel}`); applyInitialRoute(windowLabel); diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 750e5e3..bce9bad 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,6 +1,11 @@ import type { CalmUiSettings, StyleCheckSettings } from "$types"; import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "./stores/app"; +import { useLayoutStore } from "./stores/layout"; +import { usePdfExportStore } from "./stores/pdf-export"; +import { useSearchStore } from "./stores/search"; +import { useTabsStore } from "./stores/tabs"; +import { useUiStore } from "./stores/ui"; +import { useWorkspaceStore } from "./stores/workspace"; import type { EditorPresentation } from "./types"; const FOCUS_MODE_STYLE_CHECK_SETTINGS: StyleCheckSettings = { @@ -10,7 +15,7 @@ const FOCUS_MODE_STYLE_CHECK_SETTINGS: StyleCheckSettings = { }; export const useLayoutChromeState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, topBarsCollapsed: state.topBarsCollapsed, @@ -22,7 +27,7 @@ export const useLayoutChromeState = () => ); export const useLayoutChromeActions = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ setSidebarCollapsed: state.setSidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed, @@ -41,7 +46,7 @@ export const useLayoutChromeActions = () => ); export const useEditorPresentationStateRaw = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ lineNumbersVisible: state.lineNumbersVisible, textWrappingEnabled: state.textWrappingEnabled, @@ -53,7 +58,7 @@ export const useEditorPresentationStateRaw = () => ); export const useEditorPresentationActions = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ setLineNumbersVisible: state.setLineNumbersVisible, toggleLineNumbersVisible: state.toggleLineNumbersVisible, @@ -67,7 +72,7 @@ export const useEditorPresentationActions = () => ); export const useViewModeState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ isSplitView: state.isSplitView, isFocusMode: state.isFocusMode, @@ -77,7 +82,7 @@ export const useViewModeState = () => ); export const useViewModeActions = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ setSplitView: state.setSplitView, toggleSplitView: state.toggleSplitView, @@ -94,7 +99,7 @@ export const useViewModeActions = () => ); export const useWriterToolsState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ posHighlightingEnabled: state.posHighlightingEnabled, styleCheckSettings: state.styleCheckSettings, @@ -102,7 +107,7 @@ export const useWriterToolsState = () => ); export const useWriterToolsActions = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ setPosHighlightingEnabled: state.setPosHighlightingEnabled, togglePosHighlighting: state.togglePosHighlighting, @@ -115,7 +120,7 @@ export const useWriterToolsActions = () => ); export const useWorkspaceLocationsState = () => - useAppStore( + useWorkspaceStore( useShallow((state) => ({ locations: state.locations, isLoadingLocations: state.isLoadingLocations, @@ -125,7 +130,7 @@ export const useWorkspaceLocationsState = () => ); export const useWorkspaceLocationsActions = () => - useAppStore( + useWorkspaceStore( useShallow((state) => ({ setSidebarFilter: state.setSidebarFilter, setLocations: state.setLocations, @@ -137,7 +142,7 @@ export const useWorkspaceLocationsActions = () => ); export const useWorkspaceDocumentsState = () => - useAppStore( + useWorkspaceStore( useShallow((state) => ({ selectedDocPath: state.selectedDocPath, documents: state.documents, @@ -148,7 +153,7 @@ export const useWorkspaceDocumentsState = () => ); export const useWorkspaceDocumentsActions = () => - useAppStore( + useWorkspaceStore( useShallow((state) => ({ setSelectedDocPath: state.setSelectedDocPath, setDocuments: state.setDocuments, @@ -158,10 +163,10 @@ export const useWorkspaceDocumentsActions = () => ); export const useTabsState = () => - useAppStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }))); + useTabsStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId }))); export const useTabsActions = () => - useAppStore( + useTabsStore( useShallow((state) => ({ openDocumentTab: state.openDocumentTab, selectTab: state.selectTab, @@ -172,10 +177,12 @@ export const useTabsActions = () => ); export const usePdfExportState = () => - useAppStore(useShallow((state) => ({ isExportingPdf: state.isExportingPdf, pdfExportError: state.pdfExportError }))); + usePdfExportStore( + useShallow((state) => ({ isExportingPdf: state.isExportingPdf, pdfExportError: state.pdfExportError })), + ); export const usePdfExportActions = () => - useAppStore( + usePdfExportStore( useShallow((state) => ({ startPdfExport: state.startPdfExport, finishPdfExport: state.finishPdfExport, @@ -185,7 +192,7 @@ export const usePdfExportActions = () => ); export const useSearchState = () => - useAppStore( + useSearchStore( useShallow((state) => ({ searchQuery: state.searchQuery, searchResults: state.searchResults, @@ -195,7 +202,7 @@ export const useSearchState = () => ); export const useSearchActions = () => - useAppStore( + useSearchStore( useShallow((state) => ({ setSearchQuery: state.setSearchQuery, setSearchResults: state.setSearchResults, @@ -206,16 +213,16 @@ export const useSearchActions = () => ); export const useActiveSearchFilterCount = () => - useAppStore((state) => { + useSearchStore((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 }))); + useUiStore(useShallow((state) => ({ isOpen: state.layoutSettingsOpen, setOpen: state.setLayoutSettingsOpen }))); export const usePdfDialogUiState = () => - useAppStore( + useUiStore( useShallow((state) => ({ isOpen: state.pdfExportDialogOpen, setOpen: state.setPdfExportDialogOpen, @@ -232,7 +239,7 @@ export const usePdfDialogUiState = () => ); export const useGlobalCaptureSettingsState = () => - useAppStore( + useUiStore( useShallow((state) => ({ settings: state.globalCaptureSettings, setSettings: state.setGlobalCaptureSettings, @@ -241,7 +248,7 @@ export const useGlobalCaptureSettingsState = () => ); export const useLayoutSettingsChromeState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, topBarsCollapsed: state.topBarsCollapsed, @@ -253,7 +260,7 @@ export const useLayoutSettingsChromeState = () => ); export const useLayoutSettingsEditorState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ lineNumbersVisible: state.lineNumbersVisible, textWrappingEnabled: state.textWrappingEnabled, @@ -269,7 +276,7 @@ export const useLayoutSettingsEditorState = () => ); export const useLayoutSettingsFocusState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ focusModeSettings: state.focusModeSettings, setTypewriterScrollingEnabled: state.setTypewriterScrollingEnabled, @@ -278,7 +285,7 @@ export const useLayoutSettingsFocusState = () => ); export const useLayoutSettingsWriterToolsState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ posHighlightingEnabled: state.posHighlightingEnabled, styleCheckSettings: state.styleCheckSettings, @@ -291,7 +298,7 @@ export const useLayoutSettingsWriterToolsState = () => ); export const useAppHeaderBarState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ tabBarCollapsed: state.topBarsCollapsed, statusBarCollapsed: state.statusBarCollapsed, @@ -303,42 +310,58 @@ export const useAppHeaderBarState = () => ); export const useWorkspacePanelSidebarState = () => - useAppStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed }))); + useLayoutStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed }))); export const useWorkspacePanelModeState = () => - useAppStore(useShallow((state) => ({ isSplitView: state.isSplitView, isPreviewVisible: state.isPreviewVisible }))); + useLayoutStore(useShallow((state) => ({ isSplitView: state.isSplitView, isPreviewVisible: state.isPreviewVisible }))); -export const useWorkspacePanelTopBarsCollapsed = () => useAppStore((state) => state.topBarsCollapsed); +export const useWorkspacePanelTopBarsCollapsed = () => useLayoutStore((state) => state.topBarsCollapsed); -export const useWorkspacePanelStatusBarCollapsed = () => useAppStore((state) => state.statusBarCollapsed); +export const useWorkspacePanelStatusBarCollapsed = () => useLayoutStore((state) => state.statusBarCollapsed); export const useSearchOverlayState = () => - useAppStore(useShallow((state) => ({ isVisible: state.showSearch, setShowSearch: state.setShowSearch }))); + useLayoutStore(useShallow((state) => ({ isVisible: state.showSearch, setShowSearch: state.setShowSearch }))); export const useFocusModePanelState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ statusBarCollapsed: state.statusBarCollapsed, setFocusMode: state.setFocusMode })), ); -export const useSidebarState = () => - useAppStore( +export const useSidebarState = () => { + const layoutState = useLayoutStore(useShallow((state) => ({ toggleSidebarCollapsed: state.toggleSidebarCollapsed }))); + const workspaceState = useWorkspaceStore( useShallow((state) => ({ locations: state.locations, selectedLocationId: state.selectedLocationId, selectedDocPath: state.selectedDocPath, documents: state.documents, - isLoading: state.isLoadingLocations || state.isLoadingDocuments, + isLoadingLocations: state.isLoadingLocations, + isLoadingDocuments: state.isLoadingDocuments, refreshingLocationId: state.refreshingLocationId, sidebarRefreshReason: state.sidebarRefreshReason, filterText: state.sidebarFilter, setFilterText: state.setSidebarFilter, selectLocation: state.setSelectedLocation, - toggleSidebarCollapsed: state.toggleSidebarCollapsed, })), ); + return { + locations: workspaceState.locations, + selectedLocationId: workspaceState.selectedLocationId, + selectedDocPath: workspaceState.selectedDocPath, + documents: workspaceState.documents, + isLoading: workspaceState.isLoadingLocations || workspaceState.isLoadingDocuments, + refreshingLocationId: workspaceState.refreshingLocationId, + sidebarRefreshReason: workspaceState.sidebarRefreshReason, + filterText: workspaceState.filterText, + setFilterText: workspaceState.setFilterText, + selectLocation: workspaceState.selectLocation, + toggleSidebarCollapsed: layoutState.toggleSidebarCollapsed, + }; +}; + export const useToolbarState = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ isSplitView: state.isSplitView, isFocusMode: state.isFocusMode, @@ -351,7 +374,7 @@ export const useToolbarState = () => ); export const useEditorPresentationState = () => - useAppStore( + useLayoutStore( useShallow((state): EditorPresentation => ({ theme: state.theme, showLineNumbers: state.isFocusMode ? false : state.lineNumbersVisible, @@ -367,7 +390,7 @@ export const useEditorPresentationState = () => ); export const useCalmUiSettings = () => - useAppStore( + useLayoutStore( useShallow((state): CalmUiSettings & { chromeTemporarilyVisible: boolean } => ({ ...state.calmUiSettings, chromeTemporarilyVisible: state.chromeTemporarilyVisible, @@ -375,7 +398,7 @@ export const useCalmUiSettings = () => ); export const useCalmUiActions = () => - useAppStore( + useLayoutStore( useShallow((state) => ({ setCalmUiSettings: state.setCalmUiSettings, toggleCalmUi: state.toggleCalmUi, diff --git a/src/state/stores/app.ts b/src/state/stores/app.ts index 068696b..dc90715 100644 --- a/src/state/stores/app.ts +++ b/src/state/stores/app.ts @@ -1,487 +1,56 @@ -import { logger } from "$logger"; -import { DEFAULT_OPTIONS } from "$pdf/constants"; -import { globalCaptureSet, runCmd } from "$ports"; -import type { - AppStore, - EditorPresentationActions, - EditorPresentationState, - LayoutChromeActions, - LayoutChromeState, - LayoutState, - PdfExportActions, - PdfExportState, - SearchActions, - SearchState, - TabsActions, - TabsState, - UiActions, - UiState, - ViewModeActions, - ViewModeState, - WorkspaceDocumentsActions, - WorkspaceDocumentsState, - WorkspaceLocationsActions, - WorkspaceLocationsState, - WorkspaceState, - WriterToolsActions, - WriterToolsState, -} from "$state/types"; -import type { GlobalCaptureSettings, Tab } from "$types"; -import { create, type StateCreator } from "zustand"; - -function generateTabId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - - return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +import type { AppStore } from "$state/types"; +import { useMemo } from "react"; +import { resetLayoutStore, useLayoutStore } from "./layout"; +import { resetPdfExportStore, usePdfExportStore } from "./pdf-export"; +import { resetSearchStore, useSearchStore } from "./search"; +import { resetTabsStore, useTabsStore } from "./tabs"; +import { resetUiStore, useUiStore } from "./ui"; +import { resetWorkspaceStore, useWorkspaceStore } from "./workspace"; + +function getMergedState(): AppStore { + return { + ...useLayoutStore.getState(), + ...useWorkspaceStore.getState(), + ...useTabsStore.getState(), + ...usePdfExportStore.getState(), + ...useSearchStore.getState(), + ...useUiStore.getState(), + } as AppStore; } -function getInitialTheme(): "dark" | "light" { - if (typeof globalThis.matchMedia === "function") { - return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; +export function useAppStore(): AppStore; +export function useAppStore(selector: (state: AppStore) => T): T; +export function useAppStore(selector?: (state: AppStore) => T): AppStore | T { + const layout = useLayoutStore(); + const workspace = useWorkspaceStore(); + const tabs = useTabsStore(); + const pdfExport = usePdfExportStore(); + const search = useSearchStore(); + const ui = useUiStore(); + + const state = useMemo(() => ({ ...layout, ...workspace, ...tabs, ...pdfExport, ...search, ...ui }) as AppStore, [ + layout, + workspace, + tabs, + pdfExport, + search, + ui, + ]); + + if (!selector) { + return state; } - return "dark"; + return selector(state); } -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, focusMode: true }, - chromeTemporarilyVisible: false, -}); - -const getInitialEditorPresentationState = (): EditorPresentationState => ({ - lineNumbersVisible: true, - textWrappingEnabled: true, - syntaxHighlightingEnabled: true, - editorFontSize: 16, - editorFontFamily: "IBM Plex Mono", - theme: getInitialTheme(), -}); - -const getInitialViewModeState = (): ViewModeState => ({ - isSplitView: false, - isFocusMode: false, - isPreviewVisible: false, - focusModeSettings: { typewriterScrollingEnabled: true, dimmingMode: "sentence" }, -}); - -const getInitialWriterToolsState = (): WriterToolsState => ({ - posHighlightingEnabled: false, - styleCheckSettings: { - enabled: false, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], - }, -}); - -const getInitialLayoutState = (): LayoutState => ({ - ...getInitialLayoutChromeState(), - ...getInitialEditorPresentationState(), - ...getInitialViewModeState(), - ...getInitialWriterToolsState(), -}); - -const getInitialWorkspaceLocationsState = (): WorkspaceLocationsState => ({ - locations: [], - isLoadingLocations: true, - selectedLocationId: undefined, - sidebarFilter: "", -}); - -const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => ({ - selectedDocPath: undefined, - documents: [], - isLoadingDocuments: false, - refreshingLocationId: undefined, - sidebarRefreshReason: null, -}); - -const getInitialWorkspaceState = (): WorkspaceState => ({ - ...getInitialWorkspaceLocationsState(), - ...getInitialWorkspaceDocumentsState(), -}); - -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(), - - setSidebarCollapsed: (value) => set({ sidebarCollapsed: value }), - toggleSidebarCollapsed: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), - - setTopBarsCollapsed: (value) => set({ topBarsCollapsed: value }), - toggleTabBarCollapsed: () => set((state) => ({ topBarsCollapsed: !state.topBarsCollapsed })), - - setStatusBarCollapsed: (value) => set({ statusBarCollapsed: value }), - toggleStatusBarCollapsed: () => set((state) => ({ statusBarCollapsed: !state.statusBarCollapsed })), - - setShowSearch: (value) => set({ showSearch: value }), - toggleShowSearch: () => set((state) => ({ showSearch: !state.showSearch })), - - setCalmUiSettings: (settings) => set({ calmUiSettings: settings }), - toggleCalmUi: () => - set((state) => { - const nextEnabled = !state.calmUiSettings.enabled; - return { - calmUiSettings: { ...state.calmUiSettings, enabled: nextEnabled }, - sidebarCollapsed: nextEnabled, - topBarsCollapsed: nextEnabled, - statusBarCollapsed: nextEnabled, - chromeTemporarilyVisible: false, - }; - }), - setCalmUiFocusMode: (value) => set((state) => ({ calmUiSettings: { ...state.calmUiSettings, focusMode: value } })), - setChromeTemporarilyVisible: (value) => set({ chromeTemporarilyVisible: value }), - revealChromeTemporarily: () => set({ chromeTemporarilyVisible: true }), -}); - -const createEditorPresentationSlice: StateCreator< - AppStore, - [], - [], - EditorPresentationState & EditorPresentationActions -> = (set) => ({ - ...getInitialEditorPresentationState(), - - setLineNumbersVisible: (value) => set({ lineNumbersVisible: value }), - toggleLineNumbersVisible: () => set((state) => ({ lineNumbersVisible: !state.lineNumbersVisible })), - - setTextWrappingEnabled: (value) => set({ textWrappingEnabled: value }), - toggleTextWrappingEnabled: () => set((state) => ({ textWrappingEnabled: !state.textWrappingEnabled })), - - setSyntaxHighlightingEnabled: (value) => set({ syntaxHighlightingEnabled: value }), - toggleSyntaxHighlightingEnabled: () => - set((state) => ({ syntaxHighlightingEnabled: !state.syntaxHighlightingEnabled })), - - setEditorFontSize: (value) => set({ editorFontSize: Math.max(12, Math.min(24, Math.round(value))) }), - setEditorFontFamily: (value) => set({ editorFontFamily: value }), -}); - -const createViewModeSlice: StateCreator = (set) => ({ - ...getInitialViewModeState(), - - setSplitView: (value) => - set((state) => ({ isSplitView: value, isPreviewVisible: value ? true : state.isPreviewVisible })), - toggleSplitView: () => - set((state) => ({ - isSplitView: !state.isSplitView, - isPreviewVisible: state.isSplitView ? state.isPreviewVisible : true, - })), - setEditorOnlyMode: () => set({ isSplitView: false, isPreviewVisible: false }), - - setFocusMode: (value) => set({ isFocusMode: value }), - toggleFocusMode: () => set((state) => ({ isFocusMode: !state.isFocusMode })), - - setFocusModeSettings: (settings) => set({ focusModeSettings: settings }), - setTypewriterScrollingEnabled: (enabled) => - set((state) => ({ focusModeSettings: { ...state.focusModeSettings, typewriterScrollingEnabled: enabled } })), - setFocusDimmingMode: (mode) => - set((state) => ({ focusModeSettings: { ...state.focusModeSettings, dimmingMode: mode } })), - toggleTypewriterScrolling: () => - set((state) => ({ - focusModeSettings: { - ...state.focusModeSettings, - typewriterScrollingEnabled: !state.focusModeSettings.typewriterScrollingEnabled, - }, - })), - - setPreviewVisible: (value) => set({ isPreviewVisible: value }), - togglePreviewVisible: () => set((state) => ({ isPreviewVisible: !state.isPreviewVisible })), -}); - -const createWriterToolsSlice: StateCreator = (set) => ({ - ...getInitialWriterToolsState(), - - setPosHighlightingEnabled: (value) => set({ posHighlightingEnabled: value }), - togglePosHighlighting: () => set((state) => ({ posHighlightingEnabled: !state.posHighlightingEnabled })), - - setStyleCheckSettings: (settings) => set({ styleCheckSettings: settings }), - toggleStyleCheck: () => - set((state) => ({ - styleCheckSettings: { ...state.styleCheckSettings, enabled: !state.styleCheckSettings.enabled }, - })), - setStyleCheckCategory: (category, enabled) => - set((state) => ({ - styleCheckSettings: { - ...state.styleCheckSettings, - categories: { ...state.styleCheckSettings.categories, [category]: enabled }, - }, - })), - addCustomPattern: (pattern) => - set((state) => ({ - styleCheckSettings: { - ...state.styleCheckSettings, - customPatterns: [...state.styleCheckSettings.customPatterns, pattern], - }, - })), - removeCustomPattern: (index) => - set((state) => ({ - styleCheckSettings: { - ...state.styleCheckSettings, - customPatterns: state.styleCheckSettings.customPatterns.filter((_, i) => i !== index), - }, - })), -}); - -const createWorkspaceLocationsSlice: StateCreator< - AppStore, - [], - [], - WorkspaceLocationsState & WorkspaceLocationsActions -> = (set) => ({ - ...getInitialWorkspaceLocationsState(), - - setSidebarFilter: (value) => set({ sidebarFilter: value }), - - setLocations: (locations) => { - set((state) => ({ locations, selectedLocationId: state.selectedLocationId ?? locations[0]?.id })); - }, - - setLoadingLocations: (value) => set({ isLoadingLocations: value }), - - setSelectedLocation: (locationId) => set({ selectedLocationId: locationId, selectedDocPath: undefined }), - - addLocation: (location) => { - set((state) => ({ - locations: [...state.locations, location], - selectedLocationId: location.id, - selectedDocPath: undefined, - })); - }, - - removeLocation: (locationId) => { - set((state) => { - const locations = state.locations.filter((location) => location.id !== locationId); - const tabs = state.tabs.filter((tab) => tab.docRef.location_id !== locationId); - const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); - const activeTabRemoved = activeTab?.docRef.location_id === locationId; - - return { - locations, - tabs, - selectedLocationId: state.selectedLocationId === locationId ? undefined : state.selectedLocationId, - selectedDocPath: state.selectedLocationId === locationId ? undefined : state.selectedDocPath, - activeTabId: activeTabRemoved ? null : state.activeTabId, - }; - }); - }, -}); - -const createWorkspaceDocumentsSlice: StateCreator< - AppStore, - [], - [], - WorkspaceDocumentsState & WorkspaceDocumentsActions -> = (set) => ({ - ...getInitialWorkspaceDocumentsState(), - - 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) => ({ - ...getInitialTabsState(), - - openDocumentTab: (docRef, title) => { - const existingTab = get().tabs.find((tab) => - tab.docRef.location_id === docRef.location_id && tab.docRef.rel_path === docRef.rel_path - ); - - if (existingTab) { - set({ activeTabId: existingTab.id, selectedLocationId: docRef.location_id, selectedDocPath: docRef.rel_path }); - - return { tabId: existingTab.id, didCreateTab: false }; - } - - const newTab: Tab = { id: generateTabId(), docRef, title, isModified: false }; - - set((state) => ({ - tabs: [...state.tabs, newTab], - activeTabId: newTab.id, - selectedLocationId: docRef.location_id, - selectedDocPath: docRef.rel_path, - })); - - return { tabId: newTab.id, didCreateTab: true }; - }, - - selectTab: (tabId) => { - const tab = get().tabs.find((item) => item.id === tabId); - if (!tab) { - return null; - } - - set({ activeTabId: tab.id, selectedLocationId: tab.docRef.location_id, selectedDocPath: tab.docRef.rel_path }); - - return tab.docRef; - }, - - closeTab: (tabId) => { - const state = get(); - const tabIndex = state.tabs.findIndex((item) => item.id === tabId); - - if (tabIndex === -1) { - return null; - } - - const tabs = state.tabs.filter((item) => item.id !== tabId); - - if (state.activeTabId !== tabId) { - set({ tabs }); - return null; - } - - if (tabs.length === 0) { - set({ tabs, activeTabId: null, selectedDocPath: undefined }); - return null; - } - - const nextIndex = Math.min(tabIndex, tabs.length - 1); - const nextActiveTab = tabs[nextIndex]; - - set({ - tabs, - activeTabId: nextActiveTab.id, - selectedLocationId: nextActiveTab.docRef.location_id, - selectedDocPath: nextActiveTab.docRef.rel_path, - }); - - return nextActiveTab.docRef; - }, - - reorderTabs: (tabs) => set({ tabs }), - - markActiveTabModified: (isModified) => { - set((state) => { - if (!state.activeTabId) { - return state; - } - - const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); - if (!activeTab || activeTab.isModified === isModified) { - return state; - } - - return { tabs: state.tabs.map((tab) => (tab.id === state.activeTabId ? { ...tab, isModified } : tab)) }; - }); - }, -}); - -const createPdfExportSlice: StateCreator = (set) => ({ - ...getInitialPdfExportState(), - - startPdfExport: () => set({ isExportingPdf: true, pdfExportError: null }), - finishPdfExport: () => set({ isExportingPdf: false, pdfExportError: null }), - failPdfExport: (message) => set({ isExportingPdf: false, pdfExportError: message }), - resetPdfExport: () => 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), - ...createViewModeSlice(...params), - ...createWriterToolsSlice(...params), - ...createWorkspaceLocationsSlice(...params), - ...createWorkspaceDocumentsSlice(...params), - ...createTabsSlice(...params), - ...createPdfExportSlice(...params), - ...createSearchSlice(...params), - ...createUiSlice(...params), -})); +useAppStore.getState = getMergedState; export function resetAppStore(): void { - useAppStore.setState({ - ...getInitialLayoutState(), - ...getInitialWorkspaceState(), - ...getInitialTabsState(), - ...getInitialPdfExportState(), - ...getInitialSearchState(), - ...getInitialUiState(), - }); + resetLayoutStore(); + resetWorkspaceStore(); + resetTabsStore(); + resetPdfExportStore(); + resetSearchStore(); + resetUiStore(); } diff --git a/src/state/stores/layout.ts b/src/state/stores/layout.ts new file mode 100644 index 0000000..d0c855e --- /dev/null +++ b/src/state/stores/layout.ts @@ -0,0 +1,178 @@ +import type { + EditorPresentationActions, + EditorPresentationState, + LayoutChromeActions, + LayoutChromeState, + LayoutState, + ViewModeActions, + ViewModeState, + WriterToolsActions, + WriterToolsState, +} from "$state/types"; +import { create } from "zustand"; + +export type LayoutStore = + & LayoutChromeState + & LayoutChromeActions + & EditorPresentationState + & EditorPresentationActions + & ViewModeState + & ViewModeActions + & WriterToolsState + & WriterToolsActions; + +function getInitialTheme(): "dark" | "light" { + if (typeof globalThis.matchMedia === "function") { + return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + return "dark"; +} + +export const getInitialLayoutChromeState = (): LayoutChromeState => ({ + sidebarCollapsed: false, + topBarsCollapsed: false, + statusBarCollapsed: false, + showSearch: false, + calmUiSettings: { enabled: true, focusMode: true }, + chromeTemporarilyVisible: false, +}); + +export const getInitialEditorPresentationState = (): EditorPresentationState => ({ + lineNumbersVisible: true, + textWrappingEnabled: true, + syntaxHighlightingEnabled: true, + editorFontSize: 16, + editorFontFamily: "IBM Plex Mono", + theme: getInitialTheme(), +}); + +export const getInitialViewModeState = (): ViewModeState => ({ + isSplitView: false, + isFocusMode: false, + isPreviewVisible: false, + focusModeSettings: { typewriterScrollingEnabled: true, dimmingMode: "sentence" }, +}); + +export const getInitialWriterToolsState = (): WriterToolsState => ({ + posHighlightingEnabled: false, + styleCheckSettings: { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + }, +}); + +export const getInitialLayoutState = (): LayoutState => ({ + ...getInitialLayoutChromeState(), + ...getInitialEditorPresentationState(), + ...getInitialViewModeState(), + ...getInitialWriterToolsState(), +}); + +export const useLayoutStore = create()((set) => ({ + ...getInitialLayoutState(), + + setSidebarCollapsed: (value) => set({ sidebarCollapsed: value }), + toggleSidebarCollapsed: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })), + + setTopBarsCollapsed: (value) => set({ topBarsCollapsed: value }), + toggleTabBarCollapsed: () => set((state) => ({ topBarsCollapsed: !state.topBarsCollapsed })), + + setStatusBarCollapsed: (value) => set({ statusBarCollapsed: value }), + toggleStatusBarCollapsed: () => set((state) => ({ statusBarCollapsed: !state.statusBarCollapsed })), + + setShowSearch: (value) => set({ showSearch: value }), + toggleShowSearch: () => set((state) => ({ showSearch: !state.showSearch })), + + setCalmUiSettings: (settings) => set({ calmUiSettings: settings }), + toggleCalmUi: () => + set((state) => { + const nextEnabled = !state.calmUiSettings.enabled; + return { + calmUiSettings: { ...state.calmUiSettings, enabled: nextEnabled }, + sidebarCollapsed: nextEnabled, + topBarsCollapsed: nextEnabled, + statusBarCollapsed: nextEnabled, + chromeTemporarilyVisible: false, + }; + }), + setCalmUiFocusMode: (value) => set((state) => ({ calmUiSettings: { ...state.calmUiSettings, focusMode: value } })), + setChromeTemporarilyVisible: (value) => set({ chromeTemporarilyVisible: value }), + revealChromeTemporarily: () => set({ chromeTemporarilyVisible: true }), + + setLineNumbersVisible: (value) => set({ lineNumbersVisible: value }), + toggleLineNumbersVisible: () => set((state) => ({ lineNumbersVisible: !state.lineNumbersVisible })), + + setTextWrappingEnabled: (value) => set({ textWrappingEnabled: value }), + toggleTextWrappingEnabled: () => set((state) => ({ textWrappingEnabled: !state.textWrappingEnabled })), + + setSyntaxHighlightingEnabled: (value) => set({ syntaxHighlightingEnabled: value }), + toggleSyntaxHighlightingEnabled: () => + set((state) => ({ syntaxHighlightingEnabled: !state.syntaxHighlightingEnabled })), + + setEditorFontSize: (value) => set({ editorFontSize: Math.max(12, Math.min(24, Math.round(value))) }), + setEditorFontFamily: (value) => set({ editorFontFamily: value }), + + setSplitView: (value) => + set((state) => ({ isSplitView: value, isPreviewVisible: value ? true : state.isPreviewVisible })), + toggleSplitView: () => + set((state) => ({ + isSplitView: !state.isSplitView, + isPreviewVisible: state.isSplitView ? state.isPreviewVisible : true, + })), + setEditorOnlyMode: () => set({ isSplitView: false, isPreviewVisible: false }), + + setFocusMode: (value) => set({ isFocusMode: value }), + toggleFocusMode: () => set((state) => ({ isFocusMode: !state.isFocusMode })), + + setFocusModeSettings: (settings) => set({ focusModeSettings: settings }), + setTypewriterScrollingEnabled: (enabled) => + set((state) => ({ focusModeSettings: { ...state.focusModeSettings, typewriterScrollingEnabled: enabled } })), + setFocusDimmingMode: (mode) => + set((state) => ({ focusModeSettings: { ...state.focusModeSettings, dimmingMode: mode } })), + toggleTypewriterScrolling: () => + set((state) => ({ + focusModeSettings: { + ...state.focusModeSettings, + typewriterScrollingEnabled: !state.focusModeSettings.typewriterScrollingEnabled, + }, + })), + + setPreviewVisible: (value) => set({ isPreviewVisible: value }), + togglePreviewVisible: () => set((state) => ({ isPreviewVisible: !state.isPreviewVisible })), + + setPosHighlightingEnabled: (value) => set({ posHighlightingEnabled: value }), + togglePosHighlighting: () => set((state) => ({ posHighlightingEnabled: !state.posHighlightingEnabled })), + + setStyleCheckSettings: (settings) => set({ styleCheckSettings: settings }), + toggleStyleCheck: () => + set((state) => ({ + styleCheckSettings: { ...state.styleCheckSettings, enabled: !state.styleCheckSettings.enabled }, + })), + setStyleCheckCategory: (category, enabled) => + set((state) => ({ + styleCheckSettings: { + ...state.styleCheckSettings, + categories: { ...state.styleCheckSettings.categories, [category]: enabled }, + }, + })), + addCustomPattern: (pattern) => + set((state) => ({ + styleCheckSettings: { + ...state.styleCheckSettings, + customPatterns: [...state.styleCheckSettings.customPatterns, pattern], + }, + })), + removeCustomPattern: (index) => + set((state) => ({ + styleCheckSettings: { + ...state.styleCheckSettings, + customPatterns: state.styleCheckSettings.customPatterns.filter((_, i) => i !== index), + }, + })), +})); + +export function resetLayoutStore(): void { + useLayoutStore.setState(getInitialLayoutState()); +} diff --git a/src/state/stores/pdf-export.ts b/src/state/stores/pdf-export.ts new file mode 100644 index 0000000..f7c7df2 --- /dev/null +++ b/src/state/stores/pdf-export.ts @@ -0,0 +1,19 @@ +import type { PdfExportActions, PdfExportState } from "$state/types"; +import { create } from "zustand"; + +export type PdfExportStore = PdfExportState & PdfExportActions; + +export const getInitialPdfExportState = (): PdfExportState => ({ isExportingPdf: false, pdfExportError: null }); + +export const usePdfExportStore = create()((set) => ({ + ...getInitialPdfExportState(), + + startPdfExport: () => set({ isExportingPdf: true, pdfExportError: null }), + finishPdfExport: () => set({ isExportingPdf: false, pdfExportError: null }), + failPdfExport: (message) => set({ isExportingPdf: false, pdfExportError: message }), + resetPdfExport: () => set(getInitialPdfExportState()), +})); + +export function resetPdfExportStore(): void { + usePdfExportStore.setState(getInitialPdfExportState()); +} diff --git a/src/state/stores/search.ts b/src/state/stores/search.ts new file mode 100644 index 0000000..7a99831 --- /dev/null +++ b/src/state/stores/search.ts @@ -0,0 +1,25 @@ +import type { SearchActions, SearchState } from "$state/types"; +import { create } from "zustand"; + +export type SearchStore = SearchState & SearchActions; + +export const getInitialSearchState = (): SearchState => ({ + searchQuery: "", + searchResults: [], + isSearching: false, + searchFilters: {}, +}); + +export const useSearchStore = create()((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()), +})); + +export function resetSearchStore(): void { + useSearchStore.setState(getInitialSearchState()); +} diff --git a/src/state/stores/tabs.ts b/src/state/stores/tabs.ts new file mode 100644 index 0000000..c17872f --- /dev/null +++ b/src/state/stores/tabs.ts @@ -0,0 +1,134 @@ +import type { TabsActions, TabsState } from "$state/types"; +import type { Tab } from "$types"; +import { create } from "zustand"; +import { useWorkspaceStore } from "./workspace"; + +export type TabsStore = TabsState & TabsActions; + +function generateTabId(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export const getInitialTabsState = (): TabsState => ({ tabs: [], activeTabId: null }); + +export const useTabsStore = create()((set, get) => ({ + ...getInitialTabsState(), + + openDocumentTab: (docRef, title) => { + const existingTab = get().tabs.find((tab) => + tab.docRef.location_id === docRef.location_id && tab.docRef.rel_path === docRef.rel_path + ); + + if (existingTab) { + useWorkspaceStore.setState({ selectedLocationId: docRef.location_id, selectedDocPath: docRef.rel_path }); + set({ activeTabId: existingTab.id }); + return { tabId: existingTab.id, didCreateTab: false }; + } + + const newTab: Tab = { id: generateTabId(), docRef, title, isModified: false }; + set((state) => ({ tabs: [...state.tabs, newTab], activeTabId: newTab.id })); + useWorkspaceStore.setState({ selectedLocationId: docRef.location_id, selectedDocPath: docRef.rel_path }); + return { tabId: newTab.id, didCreateTab: true }; + }, + + selectTab: (tabId) => { + const tab = get().tabs.find((item) => item.id === tabId); + if (!tab) { + return null; + } + + set({ activeTabId: tab.id }); + useWorkspaceStore.setState({ selectedLocationId: tab.docRef.location_id, selectedDocPath: tab.docRef.rel_path }); + return tab.docRef; + }, + + closeTab: (tabId) => { + const state = get(); + const tabIndex = state.tabs.findIndex((item) => item.id === tabId); + + if (tabIndex === -1) { + return null; + } + + const tabs = state.tabs.filter((item) => item.id !== tabId); + + if (state.activeTabId !== tabId) { + set({ tabs }); + return null; + } + + if (tabs.length === 0) { + set({ tabs, activeTabId: null }); + useWorkspaceStore.setState({ selectedDocPath: undefined }); + return null; + } + + const nextIndex = Math.min(tabIndex, tabs.length - 1); + const nextActiveTab = tabs[nextIndex]; + set({ tabs, activeTabId: nextActiveTab.id }); + useWorkspaceStore.setState({ + selectedLocationId: nextActiveTab.docRef.location_id, + selectedDocPath: nextActiveTab.docRef.rel_path, + }); + + return nextActiveTab.docRef; + }, + + reorderTabs: (tabs) => set({ tabs }), + + markActiveTabModified: (isModified) => { + set((state) => { + if (!state.activeTabId) { + return state; + } + + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + if (!activeTab || activeTab.isModified === isModified) { + return state; + } + + return { tabs: state.tabs.map((tab) => (tab.id === state.activeTabId ? { ...tab, isModified } : tab)) }; + }); + }, +})); + +let hasInitializedWorkspaceSync = false; + +function initializeWorkspaceSync(): void { + if (hasInitializedWorkspaceSync) { + return; + } + hasInitializedWorkspaceSync = true; + + useWorkspaceStore.subscribe((workspaceState, previousWorkspaceState) => { + if (workspaceState.locations === previousWorkspaceState.locations) { + return; + } + + const validLocationIds = new Set(workspaceState.locations.map((location) => location.id)); + const tabsState = useTabsStore.getState(); + const nextTabs = tabsState.tabs.filter((tab) => validLocationIds.has(tab.docRef.location_id)); + + if (nextTabs.length === tabsState.tabs.length) { + return; + } + + const activeTabStillExists = tabsState.activeTabId !== null + && nextTabs.some((tab) => tab.id === tabsState.activeTabId); + useTabsStore.setState({ tabs: nextTabs, activeTabId: activeTabStillExists ? tabsState.activeTabId : null }); + + if (!activeTabStillExists) { + useWorkspaceStore.setState({ selectedDocPath: undefined }); + } + }); +} + +initializeWorkspaceSync(); + +export function resetTabsStore(): void { + useTabsStore.setState(getInitialTabsState()); +} diff --git a/src/state/stores/ui.ts b/src/state/stores/ui.ts new file mode 100644 index 0000000..6edfaef --- /dev/null +++ b/src/state/stores/ui.ts @@ -0,0 +1,71 @@ +import { logger } from "$logger"; +import { DEFAULT_OPTIONS } from "$pdf/constants"; +import { globalCaptureSet, runCmd } from "$ports"; +import type { UiActions, UiState } from "$state/types"; +import type { GlobalCaptureSettings } from "$types"; +import { create } from "zustand"; + +export type UiStore = UiState & UiActions; + +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 getInitialUiState = (): UiState => ({ + layoutSettingsOpen: false, + pdfExportDialogOpen: false, + pdfExportOptions: DEFAULT_OPTIONS, + globalCaptureSettings: DEFAULT_GLOBAL_CAPTURE_SETTINGS, +}); + +export const useUiStore = create()((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 function resetUiStore(): void { + useUiStore.setState(getInitialUiState()); +} diff --git a/src/state/stores/workspace.ts b/src/state/stores/workspace.ts new file mode 100644 index 0000000..d9343ea --- /dev/null +++ b/src/state/stores/workspace.ts @@ -0,0 +1,70 @@ +import type { + SidebarRefreshReason, + WorkspaceDocumentsActions, + WorkspaceDocumentsState, + WorkspaceLocationsActions, + WorkspaceLocationsState, + WorkspaceState, +} from "$state/types"; +import { create } from "zustand"; + +export type WorkspaceStore = + & WorkspaceLocationsState + & WorkspaceLocationsActions + & WorkspaceDocumentsState + & WorkspaceDocumentsActions; + +export const getInitialWorkspaceLocationsState = (): WorkspaceLocationsState => ({ + locations: [], + isLoadingLocations: true, + selectedLocationId: undefined, + sidebarFilter: "", +}); + +export const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => ({ + selectedDocPath: undefined, + documents: [], + isLoadingDocuments: false, + refreshingLocationId: undefined, + sidebarRefreshReason: null, +}); + +export const getInitialWorkspaceState = (): WorkspaceState => ({ + ...getInitialWorkspaceLocationsState(), + ...getInitialWorkspaceDocumentsState(), +}); + +export const useWorkspaceStore = create()((set) => ({ + ...getInitialWorkspaceState(), + + setSidebarFilter: (value) => set({ sidebarFilter: value }), + setLocations: (locations) => { + set((state) => ({ locations, selectedLocationId: state.selectedLocationId ?? locations[0]?.id })); + }, + setLoadingLocations: (value) => set({ isLoadingLocations: value }), + setSelectedLocation: (locationId) => set({ selectedLocationId: locationId, selectedDocPath: undefined }), + addLocation: (location) => { + set((state) => ({ + locations: [...state.locations, location], + selectedLocationId: location.id, + selectedDocPath: undefined, + })); + }, + removeLocation: (locationId) => { + set((state) => ({ + locations: state.locations.filter((location) => location.id !== locationId), + selectedLocationId: state.selectedLocationId === locationId ? undefined : state.selectedLocationId, + selectedDocPath: state.selectedLocationId === locationId ? undefined : state.selectedDocPath, + })); + }, + + setSelectedDocPath: (path) => set({ selectedDocPath: path }), + setDocuments: (documents) => set({ documents }), + setLoadingDocuments: (value) => set({ isLoadingDocuments: value }), + setSidebarRefreshState: (locationId, reason: SidebarRefreshReason | null = null) => + set({ refreshingLocationId: locationId, sidebarRefreshReason: locationId === undefined ? null : reason }), +})); + +export function resetWorkspaceStore(): void { + useWorkspaceStore.setState(getInitialWorkspaceState()); +} diff --git a/src/types.ts b/src/types.ts index 24c5714..0e8248e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -128,3 +128,5 @@ export type CaptureSubmitInput = { destination?: { locationId: number; relPath: string }; openMainAfterSave?: boolean; }; + +export type Maybe = T | null | undefined; diff --git a/tsconfig.json b/tsconfig.json index 98d2cbd..1a8c6aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,6 @@ "$editor/*": ["./editor/*"], "$pdf/*": ["./pdf/*"], "$components/*": ["./components/*"], - "$themes/*": ["./themes/*"], - "$data/*": ["./data/*"], "$state/*": ["./state/*"], "$hooks/*": ["./hooks/*"], "$utils/*": ["./utils/*"] diff --git a/vite.config.ts b/vite.config.ts index efeaf17..469436f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,11 +7,11 @@ import type { ViteUserConfig } from "vitest/config"; const test: ViteUserConfig["test"] = { environment: "jsdom", globals: true, - setupFiles: ["./src/test/setup.ts"], + setupFiles: ["./src/__tests__/setup.ts"], coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: ["node_modules/", "src/test/", "**/*.d.ts", "**/*.config.*", "**/dist/**"], + exclude: ["node_modules/", "src/__tests__/", "**/*.d.ts", "**/*.config.*", "**/dist/**"], }, include: ["src/**/*.{test,spec}.{js,ts,tsx}"], watch: false, @@ -40,8 +40,6 @@ export default defineConfig({ "$pdf": resolveFromRoot("./src/pdf"), "$components": resolveFromRoot("./src/components"), "$editor": resolveFromRoot("./src/editor"), - "$themes": resolveFromRoot("./src/themes"), - "$data": resolveFromRoot("./src/data"), "$state": resolveFromRoot("./src/state"), "$hooks": resolveFromRoot("./src/hooks"), "$utils": resolveFromRoot("./src/utils"), -- 2.51.2