diff --git a/src/App.tsx b/src/App.tsx index 83cd170..641850b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,10 +18,19 @@ import { usePreview } from "./hooks/usePreview"; import { useSearchController } from "./hooks/useSearchController"; import { useWorkspaceController } from "./hooks/useWorkspaceController"; import { useWorkspaceSync } from "./hooks/useWorkspaceSync"; -import { useLayoutActions, useLayoutState } from "./state/appStore"; +import { + useEditorPresentationActions, + useEditorPresentationState, + useLayoutChromeActions, + useLayoutChromeState, + usePdfExportActions, + usePdfExportState, + useViewModeState, + useWriterToolsActions, + useWriterToolsState, +} from "./state/appStore"; import "@fontsource-variable/ibm-plex-sans"; import "./App.css"; -import { PatternCategory } from "$editor/pattern-matcher"; // TODO: make shared utils module function formatDraftDate(date: Date): string { @@ -72,7 +81,9 @@ const ShowButton = ({ clickHandler, title, label }: { clickHandler: () => void; function App() { const { model: editorModel, dispatch: editorDispatch, openDoc } = useEditor(); const { model: previewModel, render: renderPreview, syncLine: syncPreviewLine, setDoc: setPreviewDoc } = usePreview(); - const { state: pdfExportState, exportPdf, reset: resetPdfExport } = usePdfExport(); + const exportPdf = usePdfExport(); + const { isExportingPdf, pdfExportError } = usePdfExportState(); + const { resetPdfExport } = usePdfExportActions(); const { missingLocations, conflicts } = useBackendEvents(); const [isLayoutSettingsOpen, setIsLayoutSettingsOpen] = useState(false); const [isPdfExportDialogOpen, setIsPdfExportDialogOpen] = useState(false); @@ -81,8 +92,19 @@ function App() { useWorkspaceSync(); useLayoutHotkeys(); - const layoutState = useLayoutState(); - const layoutActions = useLayoutActions(); + const layoutChrome = useLayoutChromeState(); + const { setSidebarCollapsed, setTopBarsCollapsed, setStatusBarCollapsed } = useLayoutChromeActions(); + const editorPresentation = useEditorPresentationState(); + const { + setLineNumbersVisible, + setTextWrappingEnabled, + setSyntaxHighlightingEnabled, + setEditorFontSize, + setEditorFontFamily, + } = useEditorPresentationActions(); + const { isFocusMode } = useViewModeState(); + const { styleCheckSettings } = useWriterToolsState(); + const { setStyleCheckSettings } = useWriterToolsActions(); const workspace = useWorkspaceController(openDoc); const search = useSearchController(workspace.handleSelectDocument); @@ -157,10 +179,8 @@ function App() { setIsPdfExportDialogOpen(true); }, [activeTab, resetPdfExport]); - const handleOpenSearch = useCallback(() => layoutActions.setShowSearch(true), [layoutActions]); - const handleShowSidebar = useCallback(() => layoutActions.setSidebarCollapsed(false), [layoutActions]); - const handleShowStatusBar = useCallback(() => layoutActions.setStatusBarCollapsed(false), [layoutActions]); - const handleExit = useCallback(() => layoutActions.setFocusMode(false), [layoutActions]); + const handleShowSidebar = useCallback(() => setSidebarCollapsed(false), [setSidebarCollapsed]); + const handleShowStatusBar = useCallback(() => setStatusBarCollapsed(false), [setStatusBarCollapsed]); const handleCancelPdfExport = useCallback(() => { setIsPdfExportDialogOpen(false); @@ -182,7 +202,7 @@ function App() { ); }); - const didExport = await exportPdf(renderResult, options, layoutState.editorFontFamily); + const didExport = await exportPdf(renderResult, options, editorPresentation.editorFontFamily); if (didExport) { setIsPdfExportDialogOpen(false); resetPdfExport(); @@ -190,110 +210,72 @@ function App() { } catch (error) { logger.error("Failed to export PDF", { error: error instanceof Error ? error.message : String(error) }); } - }, [activeTab, editorModel.text, exportPdf, layoutState.editorFontFamily, resetPdfExport]); + }, [activeTab, editorModel.text, exportPdf, editorPresentation.editorFontFamily, resetPdfExport]); - const showToggleControls = useMemo(() => layoutState.sidebarCollapsed || layoutState.statusBarCollapsed, [ - layoutState.sidebarCollapsed, - layoutState.statusBarCollapsed, + const showToggleControls = useMemo(() => layoutChrome.sidebarCollapsed || layoutChrome.statusBarCollapsed, [ + layoutChrome.sidebarCollapsed, + layoutChrome.statusBarCollapsed, ]); - const layoutProps = useMemo( - () => ({ - sidebarCollapsed: layoutState.sidebarCollapsed, - topBarsCollapsed: layoutState.topBarsCollapsed, - statusBarCollapsed: layoutState.statusBarCollapsed, - isSplitView: layoutState.isSplitView, - isPreviewVisible: layoutState.isPreviewVisible, - }), - [layoutState], - ); - const sidebarProps = useMemo( () => ({ - locations: workspace.locations, - selectedLocationId: workspace.selectedLocationId, - selectedDocPath: workspace.selectedDocPath, - documents: workspace.locationDocuments, - isLoading: workspace.isSidebarLoading, - filterText: workspace.sidebarFilter, - onAddLocation: workspace.handleAddLocation, - onRemoveLocation: workspace.handleRemoveLocation, - onSelectLocation: workspace.handleSelectLocation, - onSelectDocument: workspace.handleSelectDocument, - onFilterChange: workspace.setSidebarFilter, + handleAddLocation: workspace.handleAddLocation, + handleRemoveLocation: workspace.handleRemoveLocation, + handleSelectDocument: workspace.handleSelectDocument, }), - [workspace], + [workspace.handleAddLocation, workspace.handleRemoveLocation, workspace.handleSelectDocument], ); const toolbarProps = useMemo( () => ({ saveStatus: editorModel.saveStatus, - isSplitView: layoutState.isSplitView, - isFocusMode: layoutState.isFocusMode, - isPreviewVisible: layoutState.isPreviewVisible, onSave: handleSave, - onToggleSplitView: layoutActions.toggleSplitView, - onToggleFocusMode: layoutActions.toggleFocusMode, - onTogglePreview: layoutActions.togglePreviewVisible, onExportPdf: handleOpenPdfExport, - isExportingPdf: pdfExportState.isExporting, + isExportingPdf: isExportingPdf, isPdfExportDisabled: !activeTab, onOpenSettings: handleOpenSettings, }), - [ - editorModel.saveStatus, - layoutState.isSplitView, - layoutState.isFocusMode, - layoutState.isPreviewVisible, - layoutActions.toggleSplitView, - layoutActions.toggleFocusMode, - layoutActions.togglePreviewVisible, - handleOpenPdfExport, - pdfExportState.isExporting, - activeTab, - handleSave, - handleOpenSettings, - ], + [editorModel.saveStatus, handleOpenPdfExport, isExportingPdf, activeTab, handleSave, handleOpenSettings], ); const tabProps = useMemo( () => ({ tabs: workspace.tabs, activeTabId: workspace.activeTabId, - onSelectTab: workspace.handleSelectTab, - onCloseTab: workspace.handleCloseTab, - onReorderTabs: workspace.handleReorderTabs, + handleSelectTab: workspace.handleSelectTab, + handleCloseTab: workspace.handleCloseTab, + handleReorderTabs: workspace.handleReorderTabs, }), - [workspace], + [ + workspace.tabs, + workspace.activeTabId, + workspace.handleSelectTab, + workspace.handleCloseTab, + workspace.handleReorderTabs, + ], ); const editorProps = useMemo( () => ({ initialText: editorModel.text, - theme: layoutState.theme, - showLineNumbers: layoutState.lineNumbersVisible, - textWrappingEnabled: layoutState.textWrappingEnabled, - syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, - fontSize: layoutState.editorFontSize, - fontFamily: layoutState.editorFontFamily, - posHighlightingEnabled: layoutState.posHighlightingEnabled, - styleCheckSettings: layoutState.styleCheckSettings, onChange: handleEditorChange, onSave: handleSave, onCursorMove: handleCursorMove, onSelectionChange: handleSelectionChange, }), - [editorModel.text, layoutState, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange], + [editorModel.text, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange], ); const statusBarProps = useMemo( () => ({ docMeta: activeDocMeta, - cursorLine: editorModel.cursorLine, - cursorColumn: editorModel.cursorColumn, - wordCount, - charCount, - selectionCount, + stats: { + cursorLine: editorModel.cursorLine, + cursorColumn: editorModel.cursorColumn, + wordCount, + charCount, + selectionCount, + }, }), [activeDocMeta, editorModel.cursorLine, editorModel.cursorColumn, wordCount, charCount, selectionCount], ); @@ -301,112 +283,66 @@ function App() { const previewProps = useMemo( () => ({ renderResult: previewModel.renderResult, - theme: layoutState.theme, + theme: editorPresentation.theme, editorLine: editorModel.cursorLine, onScrollToLine: syncPreviewLine, }), - [previewModel.renderResult, layoutState.theme, editorModel.cursorLine, syncPreviewLine], + [previewModel.renderResult, editorPresentation.theme, editorModel.cursorLine, syncPreviewLine], ); const searchProps = useMemo( () => ({ - isVisible: layoutState.showSearch, - sidebarCollapsed: layoutState.sidebarCollapsed, - topOffset: 48, - query: search.searchQuery, - results: search.searchResults, - isSearching: search.isSearching, locations: workspace.locations, + searchQuery: search.searchQuery, + searchResults: search.searchResults, + isSearching: search.isSearching, filters: search.filters, - onQueryChange: search.handleSearch, - onFiltersChange: search.setFilters, - onSelectResult: search.handleSelectSearchResult, - onClose: () => layoutActions.setShowSearch(false), + handleSearch: search.handleSearch, + setFilters: search.setFilters, + handleSelectSearchResult: search.handleSelectSearchResult, }), - [layoutState.showSearch, layoutState.sidebarCollapsed, search, workspace.locations, layoutActions], + [ + workspace.locations, + search.searchQuery, + search.searchResults, + search.isSearching, + search.filters, + search.handleSearch, + search.setFilters, + search.handleSelectSearchResult, + ], ); const handleSettingsClose = useCallback(() => { setIsLayoutSettingsOpen(false); }, []); - const settingsPanelProps = useMemo( - () => ({ - isVisible: isLayoutSettingsOpen, - sidebarCollapsed: layoutState.sidebarCollapsed, - topBarsCollapsed: layoutState.topBarsCollapsed, - statusBarCollapsed: layoutState.statusBarCollapsed, - lineNumbersVisible: layoutState.lineNumbersVisible, - textWrappingEnabled: layoutState.textWrappingEnabled, - syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, - editorFontSize: layoutState.editorFontSize, - editorFontFamily: layoutState.editorFontFamily, - focusModeSettings: layoutState.focusModeSettings, - onSetSidebarCollapsed: layoutActions.setSidebarCollapsed, - onSetTopBarsCollapsed: layoutActions.setTopBarsCollapsed, - onSetStatusBarCollapsed: layoutActions.setStatusBarCollapsed, - onSetLineNumbersVisible: layoutActions.setLineNumbersVisible, - onSetTextWrappingEnabled: layoutActions.setTextWrappingEnabled, - onSetSyntaxHighlightingEnabled: layoutActions.setSyntaxHighlightingEnabled, - onSetEditorFontSize: layoutActions.setEditorFontSize, - onSetEditorFontFamily: layoutActions.setEditorFontFamily, - onSetTypewriterScrollingEnabled: layoutActions.setTypewriterScrollingEnabled, - onSetFocusDimmingMode: layoutActions.setFocusDimmingMode, - posHighlightingEnabled: layoutState.posHighlightingEnabled, - onSetPosHighlightingEnabled: layoutActions.setPosHighlightingEnabled, - styleCheckSettings: layoutState.styleCheckSettings, - onSetStyleCheckEnabled: (enabled: boolean) => - layoutActions.setStyleCheckSettings({ ...layoutState.styleCheckSettings, enabled }), - onSetStyleCheckCategory: (category: PatternCategory, enabled: boolean) => - layoutActions.setStyleCheckCategory(category, enabled), - onAddCustomPattern: (pattern: { text: string; category: PatternCategory; replacement?: string }) => - layoutActions.addCustomPattern(pattern), - onRemoveCustomPattern: (index: number) => layoutActions.removeCustomPattern(index), - onClose: handleSettingsClose, - }), - [isLayoutSettingsOpen, layoutState, layoutActions, handleSettingsClose], - ); - - const focusModePanelProps = useMemo( - () => ({ - theme: layoutState.theme, - text: editorModel.text, - docMeta: activeDocMeta, - cursorLine: editorModel.cursorLine, - cursorColumn: editorModel.cursorColumn, - wordCount: wordCount, - charCount: charCount, - selectionCount: selectionCount, - lineNumbersVisible: layoutState.lineNumbersVisible, - textWrappingEnabled: layoutState.textWrappingEnabled, - syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, - editorFontSize: layoutState.editorFontSize, - editorFontFamily: layoutState.editorFontFamily, - statusBarCollapsed: layoutState.statusBarCollapsed, - focusModeSettings: layoutState.focusModeSettings, - posHighlightingEnabled: layoutState.posHighlightingEnabled, - onExit: handleExit, - onEditorChange: handleEditorChange, - onSave: handleSave, - onCursorMove: handleCursorMove, - onSelectionChange: handleSelectionChange, - }), - [ - layoutState, - editorModel.text, - activeDocMeta, - editorModel.cursorLine, - editorModel.cursorColumn, - wordCount, - charCount, - selectionCount, - handleExit, - handleEditorChange, - handleSave, - handleCursorMove, - handleSelectionChange, - ], - ); + const focusModePanelProps = useMemo(() => { + const pos = { cursorLine: editorModel.cursorLine, cursorColumn: editorModel.cursorColumn }; + const stats = { ...pos, wordCount, charCount, selectionCount }; + return ({ + editor: { + initialText: editorModel.text, + onChange: handleEditorChange, + onSave: handleSave, + onCursorMove: handleCursorMove, + onSelectionChange: handleSelectionChange, + }, + statusBar: { docMeta: activeDocMeta, stats }, + }); + }, [ + editorModel.text, + activeDocMeta, + editorModel.cursorLine, + editorModel.cursorColumn, + wordCount, + charCount, + selectionCount, + handleEditorChange, + handleSave, + handleCursorMove, + handleSelectionChange, + ]); useEffect(() => { workspace.markActiveTabModified(editorModel.saveStatus === "Dirty"); @@ -438,14 +374,14 @@ function App() { return; } - layoutActions.setSidebarCollapsed(settings.sidebar_collapsed); - layoutActions.setTopBarsCollapsed(settings.top_bars_collapsed); - layoutActions.setStatusBarCollapsed(settings.status_bar_collapsed); - layoutActions.setLineNumbersVisible(settings.line_numbers_visible); - layoutActions.setTextWrappingEnabled(settings.text_wrapping_enabled); - layoutActions.setSyntaxHighlightingEnabled(settings.syntax_highlighting_enabled); - layoutActions.setEditorFontSize(settings.editor_font_size); - layoutActions.setEditorFontFamily(settings.editor_font_family); + setSidebarCollapsed(settings.sidebar_collapsed); + setTopBarsCollapsed(settings.top_bars_collapsed); + setStatusBarCollapsed(settings.status_bar_collapsed); + setLineNumbersVisible(settings.line_numbers_visible); + setTextWrappingEnabled(settings.text_wrapping_enabled); + setSyntaxHighlightingEnabled(settings.syntax_highlighting_enabled); + setEditorFontSize(settings.editor_font_size); + setEditorFontFamily(settings.editor_font_family); setLayoutSettingsHydrated(true); }, () => { if (!isCancelled) { @@ -458,7 +394,7 @@ function App() { return; } - layoutActions.setStyleCheckSettings({ + setStyleCheckSettings({ enabled: settings.enabled, categories: settings.categories, customPatterns: settings.custom_patterns, @@ -468,7 +404,17 @@ function App() { return () => { isCancelled = true; }; - }, [layoutActions]); + }, [ + setEditorFontFamily, + setEditorFontSize, + setLineNumbersVisible, + setSidebarCollapsed, + setStatusBarCollapsed, + setStyleCheckSettings, + setSyntaxHighlightingEnabled, + setTextWrappingEnabled, + setTopBarsCollapsed, + ]); useEffect(() => { if (!layoutSettingsHydrated) { @@ -478,14 +424,14 @@ function App() { void runCmd( uiLayoutSet( { - sidebar_collapsed: layoutState.sidebarCollapsed, - top_bars_collapsed: layoutState.topBarsCollapsed, - status_bar_collapsed: layoutState.statusBarCollapsed, - line_numbers_visible: layoutState.lineNumbersVisible, - text_wrapping_enabled: layoutState.textWrappingEnabled, - syntax_highlighting_enabled: layoutState.syntaxHighlightingEnabled, - editor_font_size: layoutState.editorFontSize, - editor_font_family: layoutState.editorFontFamily, + sidebar_collapsed: layoutChrome.sidebarCollapsed, + top_bars_collapsed: layoutChrome.topBarsCollapsed, + status_bar_collapsed: layoutChrome.statusBarCollapsed, + line_numbers_visible: editorPresentation.lineNumbersVisible, + text_wrapping_enabled: editorPresentation.textWrappingEnabled, + syntax_highlighting_enabled: editorPresentation.syntaxHighlightingEnabled, + editor_font_size: editorPresentation.editorFontSize, + editor_font_family: editorPresentation.editorFontFamily, }, () => {}, () => {}, @@ -493,14 +439,14 @@ function App() { ); }, [ layoutSettingsHydrated, - layoutState.sidebarCollapsed, - layoutState.topBarsCollapsed, - layoutState.statusBarCollapsed, - layoutState.lineNumbersVisible, - layoutState.textWrappingEnabled, - layoutState.syntaxHighlightingEnabled, - layoutState.editorFontSize, - layoutState.editorFontFamily, + layoutChrome.sidebarCollapsed, + layoutChrome.topBarsCollapsed, + layoutChrome.statusBarCollapsed, + editorPresentation.lineNumbersVisible, + editorPresentation.textWrappingEnabled, + editorPresentation.syntaxHighlightingEnabled, + editorPresentation.editorFontSize, + editorPresentation.editorFontFamily, ]); useEffect(() => { @@ -511,9 +457,9 @@ function App() { void runCmd( styleCheckSet( { - enabled: layoutState.styleCheckSettings.enabled, - categories: layoutState.styleCheckSettings.categories, - custom_patterns: layoutState.styleCheckSettings.customPatterns, + enabled: styleCheckSettings.enabled, + categories: styleCheckSettings.categories, + custom_patterns: styleCheckSettings.customPatterns, }, () => {}, () => {}, @@ -521,25 +467,13 @@ function App() { ); }, [ layoutSettingsHydrated, - layoutState.styleCheckSettings.enabled, - layoutState.styleCheckSettings.categories, - layoutState.styleCheckSettings.customPatterns, + styleCheckSettings.enabled, + styleCheckSettings.categories, + styleCheckSettings.customPatterns, ]); - const appHeaderBarProps = useMemo( - () => ({ - onToggleSidebar: layoutActions.toggleSidebarCollapsed, - onToggleTabBar: layoutActions.toggleTabBarCollapsed, - onOpenSearch: handleOpenSearch, - tabBarCollapsed: layoutState.topBarsCollapsed, - }), - [layoutActions, handleOpenSearch, layoutState.topBarsCollapsed], - ); - const workspacePanelProps = useMemo( () => ({ - layout: layoutProps, - onToggleSidebar: layoutActions.toggleSidebarCollapsed, sidebar: sidebarProps, toolbar: toolbarProps, tabs: tabProps, @@ -547,7 +481,7 @@ function App() { preview: previewProps, statusBar: statusBarProps, }), - [layoutProps, layoutActions, sidebarProps, toolbarProps, tabProps, editorProps, previewProps, statusBarProps], + [sidebarProps, toolbarProps, tabProps, editorProps, previewProps, statusBarProps], ); useEffect(() => { @@ -565,33 +499,33 @@ function App() { return () => globalThis.removeEventListener("keydown", closeOnEscape); }, [isLayoutSettingsOpen]); - if (layoutState.isFocusMode) { + if (isFocusMode) { return ; } return (
{showToggleControls && (
- {layoutState.sidebarCollapsed && ( + {layoutChrome.sidebarCollapsed && ( )} - {layoutState.statusBarCollapsed && ( + {layoutChrome.statusBarCollapsed && ( )}
)} - + - + diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx index 39f0b18..bc0a379 100644 --- a/src/__tests__/Editor.test.tsx +++ b/src/__tests__/Editor.test.tsx @@ -1,3 +1,4 @@ +// oxlint-disable react_perf/jsx-no-new-object-as-prop import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { Editor } from "../components/Editor"; @@ -23,7 +24,7 @@ describe(Editor, () => { }); it("should set data-theme attribute", () => { - render(); + render(); const container = screen.getByTestId("editor-container"); expect(container).toHaveAttribute("data-theme", "light"); }); @@ -40,7 +41,7 @@ describe(Editor, () => { }); it("should hide line numbers when disabled via prop", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-lineNumbers")).not.toBeInTheDocument(); }); @@ -50,12 +51,12 @@ describe(Editor, () => { }); it("should disable text wrapping when disabled via prop", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); }); it("should apply custom font family and size variables", () => { - render(); + render(); const container = screen.getByTestId("editor-container"); expect(container).toHaveStyle( "--editor-font-family: \"Writer IBM Plex Sans\", \"IBM Plex Sans\", -apple-system, BlinkMacSystemFont, sans-serif", @@ -80,13 +81,13 @@ describe(Editor, () => { describe("theme switching", () => { it("should support dark theme", () => { - render(); + render(); const container = screen.getByTestId("editor-container"); expect(container).toHaveAttribute("data-theme", "dark"); }); it("should support light theme", () => { - render(); + render(); const container = screen.getByTestId("editor-container"); expect(container).toHaveAttribute("data-theme", "light"); }); @@ -170,10 +171,10 @@ describe(Editor, () => { }); it("recreates the editor view when presentation props change", () => { - const { container, rerender } = render(); + const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(screen.getByTestId("editor-container")).toHaveAttribute("data-theme", "light"); @@ -183,9 +184,11 @@ describe(Editor, () => { }); it("recreates the editor view when line number visibility changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render( + , + ); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); @@ -195,9 +198,11 @@ describe(Editor, () => { }); it("recreates the editor view when text wrapping changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render( + , + ); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); @@ -207,9 +212,11 @@ describe(Editor, () => { }); it("recreates the editor view when syntax highlighting mode changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render( + , + ); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).toBeInTheDocument(); @@ -227,53 +234,53 @@ describe(Editor, () => { }); it("should apply typewriter scrolling when enabled", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); it("should apply focus dimming in sentence mode", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); it("should apply focus dimming in paragraph mode", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); it("should recreate editor when typewriterScrollingEnabled changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).not.toBe(firstEditorRoot); }); it("should recreate editor when focusDimmingMode changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).not.toBe(firstEditorRoot); }); it("should apply POS highlighting when enabled", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); it("should not apply POS highlighting when disabled", () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector(".cm-editor")).toBeInTheDocument(); }); it("should recreate editor when posHighlightingEnabled changes", () => { - const { container, rerender } = render(); + const { container, rerender } = render(); const firstEditorRoot = container.querySelector(".cm-editor"); - rerender(); + rerender(); const secondEditorRoot = container.querySelector(".cm-editor"); expect(secondEditorRoot).not.toBe(firstEditorRoot); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index bbc44ab..3b17a9a 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -1,46 +1,82 @@ /* oxlint-disable eslint-plugin-react-perf/jsx-no-new-object-as-prop */ +import { WorkspacePanel } from "$components/layout/WorkspacePanel"; +import { + useEditorPresentationState, + useSidebarState, + useToolbarState, + useWorkspacePanelModeState, + useWorkspacePanelSidebarState, + useWorkspacePanelStatusBarCollapsed, + useWorkspacePanelTopBarsCollapsed, +} from "$state/panel-selectors"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { WorkspacePanel } from "../components/layout/WorkspacePanel"; + +vi.mock( + "$state/panel-selectors", + () => ({ + useSidebarState: vi.fn(), + useToolbarState: vi.fn(), + useEditorPresentationState: vi.fn(), + useWorkspacePanelSidebarState: vi.fn(), + useWorkspacePanelModeState: vi.fn(), + useWorkspacePanelTopBarsCollapsed: vi.fn(), + useWorkspacePanelStatusBarCollapsed: vi.fn(), + }), +); describe("WorkspacePanel", () => { it("renders preview-only mode when preview is enabled without split view", () => { - const layout = { - sidebarCollapsed: true, - topBarsCollapsed: true, - statusBarCollapsed: true, - isSplitView: false, - isPreviewVisible: true, - } as const; - const sidebar = { + vi.mocked(useSidebarState).mockReturnValue({ locations: [], selectedLocationId: undefined, selectedDocPath: undefined, documents: [], isLoading: false, filterText: "", - onAddLocation: vi.fn(), - onRemoveLocation: vi.fn(), - onSelectLocation: vi.fn(), - onSelectDocument: vi.fn(), - onFilterChange: vi.fn(), - }; - const toolbar = { - saveStatus: "Idle" as const, + setFilterText: vi.fn(), + selectLocation: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + }); + vi.mocked(useToolbarState).mockReturnValue({ isSplitView: false, isFocusMode: false, isPreviewVisible: true, - onSave: vi.fn(), - onToggleSplitView: vi.fn(), - onToggleFocusMode: vi.fn(), - onTogglePreview: vi.fn(), - onOpenSettings: vi.fn(), + toggleSplitView: vi.fn(), + toggleFocusMode: vi.fn(), + togglePreviewVisible: vi.fn(), + }); + vi.mocked(useEditorPresentationState).mockReturnValue({ + theme: "dark", + showLineNumbers: true, + textWrappingEnabled: true, + syntaxHighlightingEnabled: true, + fontSize: 16, + fontFamily: "IBM Plex Mono", + typewriterScrollingEnabled: false, + focusDimmingMode: "off", + posHighlightingEnabled: false, + styleCheckSettings: { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + }, + }); + vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: true }); + vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: false, isPreviewVisible: true }); + vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); + vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); + const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; + const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; + const tabs = { + tabs: [], + activeTabId: null, + handleSelectTab: vi.fn(), + handleCloseTab: vi.fn(), + handleReorderTabs: vi.fn(), }; - const tabs = { tabs: [], activeTabId: null, onSelectTab: vi.fn(), onCloseTab: vi.fn(), onReorderTabs: vi.fn() }; const editor = { initialText: "# Hidden", - theme: "dark" as const, - showLineNumbers: true, onChange: vi.fn(), onSave: vi.fn(), onCursorMove: vi.fn(), @@ -55,12 +91,10 @@ describe("WorkspacePanel", () => { editorLine: 1, onScrollToLine: vi.fn(), }; - const statusBar = { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 }; + const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; const { container } = render( { it("renders sidebar controls and supports resizing", () => { const onToggleSidebar = vi.fn(); - const layout = { - sidebarCollapsed: false, - topBarsCollapsed: true, - statusBarCollapsed: true, - isSplitView: false, - isPreviewVisible: false, - } as const; - const sidebar = { + vi.mocked(useSidebarState).mockReturnValue({ locations: [], selectedLocationId: undefined, selectedDocPath: undefined, documents: [], isLoading: false, filterText: "", - onAddLocation: vi.fn(), - onRemoveLocation: vi.fn(), - onSelectLocation: vi.fn(), - onSelectDocument: vi.fn(), - onFilterChange: vi.fn(), - }; - const toolbar = { - saveStatus: "Idle" as const, + setFilterText: vi.fn(), + selectLocation: vi.fn(), + toggleSidebarCollapsed: onToggleSidebar, + }); + vi.mocked(useToolbarState).mockReturnValue({ isSplitView: false, isFocusMode: false, isPreviewVisible: false, - onSave: vi.fn(), - onToggleSplitView: vi.fn(), - onToggleFocusMode: vi.fn(), - onTogglePreview: vi.fn(), - onOpenSettings: vi.fn(), + toggleSplitView: vi.fn(), + toggleFocusMode: vi.fn(), + togglePreviewVisible: vi.fn(), + }); + vi.mocked(useEditorPresentationState).mockReturnValue({ + theme: "dark", + showLineNumbers: true, + textWrappingEnabled: true, + syntaxHighlightingEnabled: true, + fontSize: 16, + fontFamily: "IBM Plex Mono", + typewriterScrollingEnabled: false, + focusDimmingMode: "off", + posHighlightingEnabled: false, + styleCheckSettings: { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + }, + }); + vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: false }); + vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: false, isPreviewVisible: false }); + vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); + vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); + const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; + const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; + const tabs = { + tabs: [], + activeTabId: null, + handleSelectTab: vi.fn(), + handleCloseTab: vi.fn(), + handleReorderTabs: vi.fn(), }; - const tabs = { tabs: [], activeTabId: null, onSelectTab: vi.fn(), onCloseTab: vi.fn(), onReorderTabs: vi.fn() }; const editor = { initialText: "# Visible", - theme: "dark" as const, - showLineNumbers: true, onChange: vi.fn(), onSave: vi.fn(), onCursorMove: vi.fn(), @@ -125,12 +173,10 @@ describe("WorkspacePanel", () => { editorLine: 1, onScrollToLine: vi.fn(), }; - const statusBar = { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 }; + const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; render( { }); it("renders split mode and supports resizing between editor and preview", () => { - const layout = { - sidebarCollapsed: true, - topBarsCollapsed: true, - statusBarCollapsed: true, - isSplitView: true, - isPreviewVisible: true, - } as const; - const sidebar = { + vi.mocked(useSidebarState).mockReturnValue({ locations: [], selectedLocationId: undefined, selectedDocPath: undefined, documents: [], isLoading: false, filterText: "", - onAddLocation: vi.fn(), - onRemoveLocation: vi.fn(), - onSelectLocation: vi.fn(), - onSelectDocument: vi.fn(), - onFilterChange: vi.fn(), - }; - const toolbar = { - saveStatus: "Idle" as const, + setFilterText: vi.fn(), + selectLocation: vi.fn(), + toggleSidebarCollapsed: vi.fn(), + }); + vi.mocked(useToolbarState).mockReturnValue({ isSplitView: true, isFocusMode: false, isPreviewVisible: true, - onSave: vi.fn(), - onToggleSplitView: vi.fn(), - onToggleFocusMode: vi.fn(), - onTogglePreview: vi.fn(), - onOpenSettings: vi.fn(), + toggleSplitView: vi.fn(), + toggleFocusMode: vi.fn(), + togglePreviewVisible: vi.fn(), + }); + vi.mocked(useEditorPresentationState).mockReturnValue({ + theme: "dark", + showLineNumbers: true, + textWrappingEnabled: true, + syntaxHighlightingEnabled: true, + fontSize: 16, + fontFamily: "IBM Plex Mono", + typewriterScrollingEnabled: false, + focusDimmingMode: "off", + posHighlightingEnabled: false, + styleCheckSettings: { + enabled: false, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + }, + }); + vi.mocked(useWorkspacePanelSidebarState).mockReturnValue({ sidebarCollapsed: true }); + vi.mocked(useWorkspacePanelModeState).mockReturnValue({ isSplitView: true, isPreviewVisible: true }); + vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(true); + vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(true); + const sidebar = { handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn() }; + const toolbar = { saveStatus: "Idle" as const, onSave: vi.fn(), onOpenSettings: vi.fn() }; + const tabs = { + tabs: [], + activeTabId: null, + handleSelectTab: vi.fn(), + handleCloseTab: vi.fn(), + handleReorderTabs: vi.fn(), }; - const tabs = { tabs: [], activeTabId: null, onSelectTab: vi.fn(), onCloseTab: vi.fn(), onReorderTabs: vi.fn() }; const editor = { initialText: "# Split", - theme: "dark" as const, - showLineNumbers: true, onChange: vi.fn(), onSave: vi.fn(), onCursorMove: vi.fn(), @@ -205,12 +265,10 @@ describe("WorkspacePanel", () => { editorLine: 1, onScrollToLine: vi.fn(), }; - const statusBar = { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 }; + const statusBar = { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 } }; render( { @@ -143,94 +149,100 @@ describe("appStore", () => { expect(useAppStore.getState().tabs).toStrictEqual([]); }); - it("layout selector hooks expose and update layout state", () => { - const { result: layoutState } = renderHook(() => useLayoutState()); - const { result: layoutActions } = renderHook(() => useLayoutActions()); - - expect(layoutState.current.sidebarCollapsed).toBeFalsy(); - expect(layoutState.current.topBarsCollapsed).toBeFalsy(); - expect(layoutState.current.statusBarCollapsed).toBeFalsy(); - expect(layoutState.current.lineNumbersVisible).toBeTruthy(); - expect(layoutState.current.textWrappingEnabled).toBeTruthy(); - expect(layoutState.current.syntaxHighlightingEnabled).toBeTruthy(); - expect(layoutState.current.editorFontSize).toBe(16); - expect(layoutState.current.editorFontFamily).toBe("IBM Plex Mono"); - expect(layoutState.current.isSplitView).toBeFalsy(); - expect(layoutState.current.isFocusMode).toBeFalsy(); + it("focused layout hooks expose and update layout state", () => { + const { result: chromeState } = renderHook(() => useLayoutChromeState()); + const { result: chromeActions } = renderHook(() => useLayoutChromeActions()); + const { result: editorState } = renderHook(() => useEditorPresentationState()); + const { result: editorActions } = renderHook(() => useEditorPresentationActions()); + const { result: viewModeState } = renderHook(() => useViewModeState()); + const { result: viewModeActions } = renderHook(() => useViewModeActions()); + + expect(chromeState.current.sidebarCollapsed).toBeFalsy(); + expect(chromeState.current.topBarsCollapsed).toBeFalsy(); + expect(chromeState.current.statusBarCollapsed).toBeFalsy(); + expect(editorState.current.lineNumbersVisible).toBeTruthy(); + expect(editorState.current.textWrappingEnabled).toBeTruthy(); + expect(editorState.current.syntaxHighlightingEnabled).toBeTruthy(); + expect(editorState.current.editorFontSize).toBe(16); + expect(editorState.current.editorFontFamily).toBe("IBM Plex Mono"); + expect(viewModeState.current.isSplitView).toBeFalsy(); + expect(viewModeState.current.isFocusMode).toBeFalsy(); act(() => { - layoutActions.current.toggleSidebarCollapsed(); - layoutActions.current.toggleTabBarCollapsed(); - layoutActions.current.toggleStatusBarCollapsed(); - layoutActions.current.toggleLineNumbersVisible(); - layoutActions.current.toggleTextWrappingEnabled(); - layoutActions.current.toggleSyntaxHighlightingEnabled(); - layoutActions.current.setEditorFontSize(20); - layoutActions.current.setEditorFontFamily("Monaspace Neon"); - layoutActions.current.setSplitView(true); - layoutActions.current.toggleFocusMode(); - layoutActions.current.setPreviewVisible(false); - layoutActions.current.setShowSearch(true); + chromeActions.current.toggleSidebarCollapsed(); + chromeActions.current.toggleTabBarCollapsed(); + chromeActions.current.toggleStatusBarCollapsed(); + chromeActions.current.setShowSearch(true); + editorActions.current.toggleLineNumbersVisible(); + editorActions.current.toggleTextWrappingEnabled(); + editorActions.current.toggleSyntaxHighlightingEnabled(); + editorActions.current.setEditorFontSize(20); + editorActions.current.setEditorFontFamily("Monaspace Neon"); + viewModeActions.current.setSplitView(true); + viewModeActions.current.toggleFocusMode(); + viewModeActions.current.setPreviewVisible(false); }); - expect(layoutState.current.sidebarCollapsed).toBeTruthy(); - expect(layoutState.current.topBarsCollapsed).toBeTruthy(); - expect(layoutState.current.statusBarCollapsed).toBeTruthy(); - expect(layoutState.current.lineNumbersVisible).toBeFalsy(); - expect(layoutState.current.textWrappingEnabled).toBeFalsy(); - expect(layoutState.current.syntaxHighlightingEnabled).toBeFalsy(); - expect(layoutState.current.editorFontSize).toBe(20); - expect(layoutState.current.editorFontFamily).toBe("Monaspace Neon"); - expect(layoutState.current.isSplitView).toBeTruthy(); - expect(layoutState.current.isFocusMode).toBeTruthy(); - expect(layoutState.current.isPreviewVisible).toBeFalsy(); - expect(layoutState.current.showSearch).toBeTruthy(); - expect(layoutState.current.theme).toBe("dark"); + expect(chromeState.current.sidebarCollapsed).toBeTruthy(); + expect(chromeState.current.topBarsCollapsed).toBeTruthy(); + expect(chromeState.current.statusBarCollapsed).toBeTruthy(); + expect(chromeState.current.showSearch).toBeTruthy(); + expect(editorState.current.lineNumbersVisible).toBeFalsy(); + expect(editorState.current.textWrappingEnabled).toBeFalsy(); + expect(editorState.current.syntaxHighlightingEnabled).toBeFalsy(); + expect(editorState.current.editorFontSize).toBe(20); + expect(editorState.current.editorFontFamily).toBe("Monaspace Neon"); + expect(editorState.current.theme).toBe("dark"); + expect(viewModeState.current.isSplitView).toBeTruthy(); + expect(viewModeState.current.isFocusMode).toBeTruthy(); + expect(viewModeState.current.isPreviewVisible).toBeFalsy(); }); it("enabling split view forces preview visible", () => { - const { result: layoutState } = renderHook(() => useLayoutState()); - const { result: layoutActions } = renderHook(() => useLayoutActions()); + const { result: viewModeState } = renderHook(() => useViewModeState()); + const { result: viewModeActions } = renderHook(() => useViewModeActions()); act(() => { - layoutActions.current.setPreviewVisible(false); - layoutActions.current.toggleSplitView(); + viewModeActions.current.setPreviewVisible(false); + viewModeActions.current.toggleSplitView(); }); - expect(layoutState.current.isSplitView).toBeTruthy(); - expect(layoutState.current.isPreviewVisible).toBeTruthy(); + expect(viewModeState.current.isSplitView).toBeTruthy(); + expect(viewModeState.current.isPreviewVisible).toBeTruthy(); }); - it("workspace selector hooks expose and update workspace state", () => { - const { result: workspaceState } = renderHook(() => useWorkspaceState()); - const { result: workspaceActions } = renderHook(() => useWorkspaceActions()); + it("focused workspace hooks expose and update workspace state", () => { + const { result: locationsState } = renderHook(() => useWorkspaceLocationsState()); + const { result: locationsActions } = renderHook(() => useWorkspaceLocationsActions()); + const { result: documentsState } = renderHook(() => useWorkspaceDocumentsState()); + const { result: documentsActions } = renderHook(() => useWorkspaceDocumentsActions()); act(() => { - workspaceActions.current.setLoadingLocations(false); - workspaceActions.current.setSidebarFilter("draft"); - workspaceActions.current.setDocuments([{ + locationsActions.current.setLoadingLocations(false); + locationsActions.current.setSidebarFilter("draft"); + documentsActions.current.setDocuments([{ location_id: 1, rel_path: "a.md", title: "A", updated_at: "2024-01-01T00:00:00Z", word_count: 10, }]); - workspaceActions.current.setLoadingDocuments(true); - workspaceActions.current.addLocation({ id: 9, name: "N", root_path: "/n", added_at: "2024-01-01" }); - workspaceActions.current.removeLocation(9); + documentsActions.current.setLoadingDocuments(true); + locationsActions.current.addLocation({ id: 9, name: "N", root_path: "/n", added_at: "2024-01-01" }); + locationsActions.current.removeLocation(9); }); - expect(workspaceState.current.isLoadingLocations).toBeFalsy(); - expect(workspaceState.current.sidebarFilter).toBe("draft"); - expect(workspaceState.current.documents).toStrictEqual([{ + expect(locationsState.current.isLoadingLocations).toBeFalsy(); + expect(locationsState.current.sidebarFilter).toBe("draft"); + expect(documentsState.current.documents).toStrictEqual([{ location_id: 1, rel_path: "a.md", title: "A", updated_at: "2024-01-01T00:00:00Z", word_count: 10, }]); - expect(workspaceState.current.isLoadingDocuments).toBeTruthy(); - expect(workspaceState.current.locations).toStrictEqual([]); + expect(documentsState.current.isLoadingDocuments).toBeTruthy(); + expect(locationsState.current.locations).toStrictEqual([]); }); it("tabs selector hooks expose and update tab state", () => { diff --git a/src/__tests__/pos-highlighting.test.ts b/src/__tests__/pos-highlighting.test.ts index 717f0fc..b1c97ce 100644 --- a/src/__tests__/pos-highlighting.test.ts +++ b/src/__tests__/pos-highlighting.test.ts @@ -16,10 +16,7 @@ describe("posHighlighting", () => { }); it("should handle empty text", () => { - const state = EditorState.create({ - doc: "", - extensions: [posHighlighting(), posHighlightingTheme], - }); + const state = EditorState.create({ doc: "", extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); expect(view.state.doc.toString()).toBe(""); @@ -27,10 +24,7 @@ describe("posHighlighting", () => { }); it("should handle whitespace-only text", () => { - const state = EditorState.create({ - doc: " \n\n ", - extensions: [posHighlighting(), posHighlightingTheme], - }); + const state = EditorState.create({ doc: " \n\n ", extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); expect(view.state.doc.toString()).toBe(" \n\n "); @@ -39,10 +33,7 @@ describe("posHighlighting", () => { it("should handle long text with viewport", () => { const longText = "The quick brown fox jumps over the lazy dog. ".repeat(100); - const state = EditorState.create({ - doc: longText, - extensions: [posHighlighting(), posHighlightingTheme], - }); + const state = EditorState.create({ doc: longText, extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); expect(view.state.doc.toString()).toBe(longText); diff --git a/src/__tests__/usePdfExport.test.tsx b/src/__tests__/usePdfExport.test.tsx index 0f75714..a3905e0 100644 --- a/src/__tests__/usePdfExport.test.tsx +++ b/src/__tests__/usePdfExport.test.tsx @@ -8,6 +8,7 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { usePdfExport } from "../hooks/usePdfExport"; +import { useAppStore } from "../state/appStore"; vi.mock("$components/pdf/MarkdownPdfDocument", () => ({ MarkdownPdfDocument: () => null })); @@ -58,6 +59,7 @@ describe(usePdfExport, () => { beforeEach(() => { vi.clearAllMocks(); toBlobMock.mockReset(); + useAppStore.getState().resetPdfExport(); }); it("retries with built-in fonts on any custom render failure", async () => { @@ -70,7 +72,7 @@ describe(usePdfExport, () => { let didExport = false; await act(async () => { - didExport = await result.current.exportPdf(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable"); + didExport = await result.current(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable"); }); expect(didExport).toBeTruthy(); @@ -102,14 +104,14 @@ describe(usePdfExport, () => { const { result } = renderHook(() => usePdfExport()); await act(async () => { - await expect(result.current.exportPdf(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable")).rejects.toThrow( + await expect(result.current(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable")).rejects.toThrow( "Failed to render PDF using both custom and built-in fonts. Check logs for details.", ); }); expect(vi.mocked(save)).not.toHaveBeenCalled(); - expect(result.current.state.isExporting).toBeFalsy(); - expect(result.current.state.error).toBe( + expect(useAppStore.getState().isExportingPdf).toBeFalsy(); + expect(useAppStore.getState().pdfExportError).toBe( "Failed to render PDF using both custom and built-in fonts. Check logs for details.", ); expect(vi.mocked(logger.error)).toHaveBeenCalledWith( diff --git a/src/components/DocumentTabs/DocumentTabs.tsx b/src/components/DocumentTabs/DocumentTabs.tsx index 456575c..b4fd70d 100644 --- a/src/components/DocumentTabs/DocumentTabs.tsx +++ b/src/components/DocumentTabs/DocumentTabs.tsx @@ -5,12 +5,14 @@ import { DocumentTab } from "./DocumentTab"; export type DocumentTabsProps = { tabs: Tab[]; activeTabId: string | null; - onSelectTab: (tabId: string) => void; - onCloseTab: (tabId: string) => void; - onReorderTabs?: (tabs: Tab[]) => void; + handleSelectTab: (tabId: string) => void; + handleCloseTab: (tabId: string) => void; + handleReorderTabs?: (tabs: Tab[]) => void; }; -export function DocumentTabs({ tabs, activeTabId, onSelectTab, onCloseTab, onReorderTabs }: DocumentTabsProps) { +export function DocumentTabs( + { tabs, activeTabId, handleSelectTab, handleCloseTab, handleReorderTabs }: DocumentTabsProps, +) { const [draggingTab, setDraggingTab] = useState(null); const [dragOverTab, setDragOverTab] = useState(null); const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); @@ -38,19 +40,19 @@ export function DocumentTabs({ tabs, activeTabId, onSelectTab, onCloseTab, onReo const handleDrop = useCallback((e: React.DragEvent, targetTabId: string) => { e.preventDefault(); - if (draggingTab && draggingTab !== targetTabId && onReorderTabs) { + if (draggingTab && draggingTab !== targetTabId && handleReorderTabs) { const newTabs = [...tabs]; const fromIndex = newTabs.findIndex((t) => t.id === draggingTab); const toIndex = newTabs.findIndex((t) => t.id === targetTabId); if (fromIndex !== -1 && toIndex !== -1) { const [movedTab] = newTabs.splice(fromIndex, 1); newTabs.splice(toIndex, 0, movedTab); - onReorderTabs(newTabs); + handleReorderTabs(newTabs); } } setDraggingTab(null); setDragOverTab(null); - }, [draggingTab, onReorderTabs, tabs]); + }, [draggingTab, handleReorderTabs, tabs]); const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { e.preventDefault(); @@ -59,28 +61,28 @@ export function DocumentTabs({ tabs, activeTabId, onSelectTab, onCloseTab, onReo const closeContextMenu = useCallback(() => { if (contextMenu) { - onCloseTab(contextMenu.tabId); + handleCloseTab(contextMenu.tabId); setContextMenu(null); } - }, [contextMenu, onCloseTab]); + }, [contextMenu, handleCloseTab]); const closeOthers = useCallback(() => { if (contextMenu) { for (const t of tabs) { if (t.id !== contextMenu.tabId) { - onCloseTab(t.id); + handleCloseTab(t.id); } } setContextMenu(null); } - }, [contextMenu, onCloseTab, tabs]); + }, [contextMenu, handleCloseTab, tabs]); const closeAll = useCallback(() => { for (const t of tabs) { - onCloseTab(t.id); + handleCloseTab(t.id); } setContextMenu(null); - }, [tabs, onCloseTab]); + }, [tabs, handleCloseTab]); const contextMenuStyle = useMemo(() => contextMenu ? ({ left: contextMenu.x, top: contextMenu.y }) : {}, [ contextMenu, @@ -109,8 +111,8 @@ export function DocumentTabs({ tabs, activeTabId, onSelectTab, onCloseTab, onReo handleDragOver={handleDragOver} handleDrop={handleDrop} handleContextMenu={handleContextMenu} - onSelectTab={onSelectTab} - onCloseTab={onCloseTab} /> + onSelectTab={handleSelectTab} + onCloseTab={handleCloseTab} /> ))} {contextMenu && ( diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 5e063b1..d1165ba 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,7 +1,8 @@ import { focusDimming, focusDimmingTheme } from "$editor/focus-dimming"; import { posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting"; -import { type StyleMatch, styleCheck, styleCheckTheme } from "$editor/style-check"; +import { styleCheck, styleCheckTheme, type StyleMatch } from "$editor/style-check"; import { typewriterScroll } from "$editor/typewriter-scroll"; +import { useEditorPresentationState } from "$state/panel-selectors"; import { oxocarbonDark } from "$themes/oxocarbon-dark"; import { oxocarbonLight } from "$themes/oxocarbon-light"; import type { AppTheme, EditorFontFamily, FocusDimmingMode, StyleCheckSettings } from "$types"; @@ -15,21 +16,27 @@ import type { CSSProperties } from "react"; export type EditorTheme = AppTheme; +export type EditorPresentationOverrides = Partial< + { + theme: EditorTheme; + showLineNumbers: boolean; + textWrappingEnabled: boolean; + syntaxHighlightingEnabled: boolean; + fontSize: number; + fontFamily: EditorFontFamily; + typewriterScrollingEnabled: boolean; + focusDimmingMode: FocusDimmingMode; + posHighlightingEnabled: boolean; + styleCheckSettings: StyleCheckSettings; + } +>; + export type EditorProps = { initialText?: string; - theme?: EditorTheme; disabled?: boolean; - showLineNumbers?: boolean; - textWrappingEnabled?: boolean; - syntaxHighlightingEnabled?: boolean; - fontSize?: number; - fontFamily?: EditorFontFamily; placeholder?: string; debounceMs?: number; - typewriterScrollingEnabled?: boolean; - focusDimmingMode?: FocusDimmingMode; - posHighlightingEnabled?: boolean; - styleCheckSettings?: StyleCheckSettings; + presentation?: EditorPresentationOverrides; onChange?: (text: string) => void; onSave?: () => void; onCursorMove?: (line: number, column: number) => void; @@ -38,7 +45,10 @@ export type EditorProps = { className?: string; }; -type EditorCallbacks = Pick; +type EditorCallbacks = Pick< + EditorProps, + "onChange" | "onSave" | "onCursorMove" | "onSelectionChange" | "onStyleMatchesChange" +>; type CreateEditorStateOptions = { doc: string; @@ -138,19 +148,10 @@ function createEditorState( export function Editor( { initialText = "", - theme = "dark", disabled = false, - showLineNumbers = true, - textWrappingEnabled = true, - syntaxHighlightingEnabled = true, - fontSize = 16, - fontFamily = "IBM Plex Mono", placeholder, debounceMs = 500, - typewriterScrollingEnabled = false, - focusDimmingMode = "off", - posHighlightingEnabled = false, - styleCheckSettings = { enabled: false, categories: { filler: true, redundancy: true, cliche: true }, customPatterns: [] }, + presentation, onChange, onSave, onCursorMove, @@ -159,9 +160,27 @@ export function Editor( className = "", }: EditorProps, ) { + const defaults = useEditorPresentationState(); + const theme = presentation?.theme ?? defaults.theme; + const showLineNumbers = presentation?.showLineNumbers ?? defaults.showLineNumbers; + const textWrappingEnabled = presentation?.textWrappingEnabled ?? defaults.textWrappingEnabled; + const syntaxHighlightingEnabled = presentation?.syntaxHighlightingEnabled ?? defaults.syntaxHighlightingEnabled; + const fontSize = presentation?.fontSize ?? defaults.fontSize; + const fontFamily = presentation?.fontFamily ?? defaults.fontFamily; + const typewriterScrollingEnabled = presentation?.typewriterScrollingEnabled ?? defaults.typewriterScrollingEnabled; + const focusDimmingMode = presentation?.focusDimmingMode ?? defaults.focusDimmingMode; + const posHighlightingEnabled = presentation?.posHighlightingEnabled ?? defaults.posHighlightingEnabled; + const styleCheckSettings = presentation?.styleCheckSettings ?? defaults.styleCheckSettings; + const containerRef = useRef(null); const viewRef = useRef(null); - const callbacksRef = useRef({ onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange }); + const callbacksRef = useRef({ + onChange, + onSave, + onCursorMove, + onSelectionChange, + onStyleMatchesChange, + }); const debounceMsRef = useRef(debounceMs); const onChangeTimeoutRef = useRef | null>(null); const initialTextRef = useRef(initialText); diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index ebc2c36..07da568 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ -import { CollapseIcon, LibraryIcon } from "$icons"; -import type { DocMeta, LocationDescriptor } from "$types"; +import { CollapseIcon } from "$icons"; +import { useSidebarState } from "$state/panel-selectors"; import type { ChangeEventHandler, MouseEventHandler } from "react"; import { useCallback, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; @@ -9,26 +9,16 @@ import { SidebarLocationItem } from "./SidebarLocationItem"; import { Title } from "./Title"; export type SidebarProps = { - locations: LocationDescriptor[]; - selectedLocationId?: number; - selectedDocPath?: string; - documents: DocMeta[]; - isCollapsed?: boolean; - isLoading?: boolean; - onAddLocation: () => void; - onRemoveLocation: (locationId: number) => void; - onSelectLocation: (locationId: number) => void; - onSelectDocument: (locationId: number, path: string) => void; - filterText?: string; - onFilterChange?: (text: string) => void; - onToggleCollapse?: () => void; + handleAddLocation: () => void; + handleRemoveLocation: (locationId: number) => void; + handleSelectDocument: (locationId: number, path: string) => void; }; type SidebarActionsProps = { onAddLocation: () => void; handleMouseEnter: MouseEventHandler; handleMouseLeave: MouseEventHandler; - onToggleCollapse?: () => void; + onToggleCollapse: () => void; }; const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void }) => ( @@ -47,30 +37,30 @@ const SidebarActions = ( ) => (
- {onToggleCollapse ? : null} +
); -export function Sidebar( - { +export function Sidebar({ handleAddLocation, handleRemoveLocation, handleSelectDocument }: SidebarProps) { + const { locations, selectedLocationId, selectedDocPath, documents, - isCollapsed = false, - isLoading = false, - onAddLocation, - onRemoveLocation, - onSelectLocation, - onSelectDocument, - filterText = "", - onFilterChange, - onToggleCollapse, - }: SidebarProps, -) { + isLoading, + filterText, + setFilterText, + selectLocation, + toggleSidebarCollapsed, + } = useSidebarState(); const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); const [showLocationMenu, setShowLocationMenu] = useState(null); + const locationDocuments = useMemo( + () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), + [documents, selectedLocationId], + ); + const toggleLocation = useCallback((locationId: number) => { setExpandedLocations((prev) => { const next = new Set(prev); @@ -86,12 +76,12 @@ export function Sidebar( const filteredDocuments = useMemo( () => filterText - ? documents.filter((doc) => + ? locationDocuments.filter((doc) => doc.title.toLowerCase().includes(filterText.toLowerCase()) || doc.rel_path.toLowerCase().includes(filterText.toLowerCase()) ) - : documents, - [documents, filterText], + : locationDocuments, + [locationDocuments, filterText], ); const handleMouseEnter: MouseEventHandler = useCallback((e) => { @@ -103,35 +93,23 @@ export function Sidebar( }, []); const handleInputChange: ChangeEventHandler = useCallback((e) => { - onFilterChange?.(e.currentTarget.value); - }, [onFilterChange]); - - if (isCollapsed) { - return ( - - ); - } + setFilterText(e.currentTarget.value); + }, [setFilterText]); return (