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"),