From 3d0629f5dd024c9baf77a94bf7a63f5b3c1ac23d Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 26 Feb 2026 14:47:13 -0600 Subject: [PATCH] refactor: centralize backend event handling --- src/App.css | 16 +- src/__tests__/useBackendEvents.test.tsx | 52 ++++ src/__tests__/usePorts.test.tsx | 111 -------- src/components/Dialog.tsx | 66 ++++- src/components/Editor.tsx | 23 ++ src/components/SearchPanel.tsx | 29 +-- src/components/Sidebar/AddButton.tsx | 10 +- src/components/Sidebar/RemoveButton.tsx | 15 +- src/components/Sidebar/Sidebar.tsx | 49 +--- .../Sidebar/SidebarLocationItem.tsx | 31 +-- src/hooks/controllers/useAppController.ts | 10 +- .../controllers/useWorkspaceController.ts | 21 +- src/hooks/useBackendEvents.ts | 237 ++++++++++++------ src/hooks/useCmdLoop.ts | 51 ++-- src/hooks/useWorkspaceSync.ts | 29 +-- src/ports/types.ts | 13 +- src/state/stores/app.ts | 20 +- src/usePorts.ts | 37 --- 18 files changed, 399 insertions(+), 421 deletions(-) create mode 100644 src/__tests__/useBackendEvents.test.tsx delete mode 100644 src/__tests__/usePorts.test.tsx delete mode 100644 src/usePorts.ts diff --git a/src/App.css b/src/App.css index 0f1e752..f8f19a1 100644 --- a/src/App.css +++ b/src/App.css @@ -314,7 +314,7 @@ --color-accent-purple: #be95ff; --color-accent-red: #ee5396; --color-accent-teal: #08bdba; - --color-accent-yellow: #ff6f00; + --color-accent-yellow: #f1c21b; --spacing-sidebar: 280px; --spacing-sidebar-collapsed: 48px; @@ -387,6 +387,12 @@ body, color: #f2f4f8; } +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + } +} + [data-theme="light"] { color-scheme: light; --color-bg-primary: #f2f4f8; @@ -428,7 +434,7 @@ body, --color-accent-purple: #be95ff; --color-accent-red: #ff7eb6; --color-accent-teal: #673ab7; - --color-accent-yellow: #ff6f00; + --color-accent-yellow: #8e6a00; } [data-theme="light"] * { @@ -482,7 +488,7 @@ body, max-width: 46rem; margin: 0 auto; line-height: 1.7; - font-size: 1.02rem; + font-size: 1rem; } .preview-content :is(h1, h2, h3, h4, h5, h6) { @@ -525,7 +531,7 @@ body, .preview-content code { background: var(--color-layer-01); padding: 0.2em 0.4em; - border-radius: 3px; + border-radius: var(--radius-md); font-family: var(--font-mono); font-size: 0.9em; } @@ -533,7 +539,7 @@ body, .preview-content pre { background: var(--color-layer-01); padding: 1em; - border-radius: 6px; + border-radius: var(--radius-lg); overflow-x: auto; margin: 0 0 1em 0; } diff --git a/src/__tests__/useBackendEvents.test.tsx b/src/__tests__/useBackendEvents.test.tsx new file mode 100644 index 0000000..fd25973 --- /dev/null +++ b/src/__tests__/useBackendEvents.test.tsx @@ -0,0 +1,52 @@ +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"; + +describe(useBackendEvents, () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("invokes LocationMissing callback", () => { + const onLocationMissing = vi.fn(); + + renderHook(() => useBackendEvents({ onLocationMissing })); + + act(() => { + emitBackendEvent({ type: "LocationMissing", location_id: 42, path: "/missing/path" }); + }); + + expect(onLocationMissing).toHaveBeenCalledWith(42, "/missing/path"); + }); + + it("invokes latest callback after rerender", () => { + const onLocationMissing1 = vi.fn(); + const onLocationMissing2 = vi.fn(); + + const { rerender } = renderHook(({ handler }) => useBackendEvents({ onLocationMissing: handler }), { + initialProps: { handler: onLocationMissing1 }, + }); + + rerender({ handler: onLocationMissing2 }); + act(() => { + emitBackendEvent({ type: "LocationMissing", location_id: 1, path: "/test" }); + }); + + expect(onLocationMissing1).not.toHaveBeenCalled(); + expect(onLocationMissing2).toHaveBeenCalledWith(1, "/test"); + }); + + it("cleans up listener on unmount", async () => { + const onLocationMissing = vi.fn(); + const { unmount } = renderHook(() => useBackendEvents({ onLocationMissing })); + + await waitFor(() => { + expect(true).toBeTruthy(); + }); + + unmount(); + emitBackendEvent({ type: "LocationMissing", location_id: 1, path: "/test" }); + expect(onLocationMissing).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/usePorts.test.tsx b/src/__tests__/usePorts.test.tsx deleted file mode 100644 index 385f7dc..0000000 --- a/src/__tests__/usePorts.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type { AppError } from "$types"; -import { invoke } from "@tauri-apps/api/core"; -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 { usePorts } from "../usePorts"; - -describe(usePorts, () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("has initial state", () => { - const { result } = renderHook(() => usePorts()); - - expect(result.current.data).toBeNull(); - expect(result.current.error).toBeNull(); - expect(result.current.loading).toBeFalsy(); - }); - - it("executes invoke commands", async () => { - const onOk = vi.fn(); - vi.mocked(invoke).mockResolvedValueOnce({ type: "ok", value: "success" }); - - const { result } = renderHook(() => usePorts()); - - await act(async () => { - await result.current.execute({ type: "Invoke", command: "test", payload: {}, onOk, onErr: vi.fn() }); - }); - - expect(onOk).toHaveBeenCalledWith("success"); - expect(result.current.loading).toBeFalsy(); - }); - - it("ignores None command", async () => { - const { result } = renderHook(() => usePorts()); - - await act(async () => { - await result.current.execute({ type: "None" }); - }); - - expect(result.current.loading).toBeFalsy(); - expect(invoke).not.toHaveBeenCalled(); - }); - - it("captures execution errors", async () => { - const onErr = vi.fn(); - vi.mocked(invoke).mockRejectedValueOnce(new Error("boom")); - - const { result } = renderHook(() => usePorts()); - - await act(async () => { - await result.current.execute({ type: "Invoke", command: "test", payload: {}, onOk: vi.fn(), onErr }); - }); - - expect(onErr).toHaveBeenCalledWith( - expect.objectContaining({ code: "IO_ERROR", message: "boom" } satisfies Partial), - ); - expect(result.current.error).toBeNull(); - expect(result.current.loading).toBeFalsy(); - }); -}); - -describe(useBackendEvents, () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("invokes LocationMissing callback", () => { - const onLocationMissing = vi.fn(); - - renderHook(() => useBackendEvents({ onLocationMissing })); - - act(() => { - emitBackendEvent({ type: "LocationMissing", location_id: 42, path: "/missing/path" }); - }); - - expect(onLocationMissing).toHaveBeenCalledWith(42, "/missing/path"); - }); - - it("invokes latest callback after rerender", () => { - const onLocationMissing1 = vi.fn(); - const onLocationMissing2 = vi.fn(); - - const { rerender } = renderHook(({ handler }) => useBackendEvents({ onLocationMissing: handler }), { - initialProps: { handler: onLocationMissing1 }, - }); - - rerender({ handler: onLocationMissing2 }); - act(() => { - emitBackendEvent({ type: "LocationMissing", location_id: 1, path: "/test" }); - }); - - expect(onLocationMissing1).not.toHaveBeenCalled(); - expect(onLocationMissing2).toHaveBeenCalledWith(1, "/test"); - }); - - it("cleans up listener on unmount", async () => { - const onLocationMissing = vi.fn(); - const { unmount } = renderHook(() => useBackendEvents({ onLocationMissing })); - - await waitFor(() => { - expect(true).toBeTruthy(); - }); - - unmount(); - emitBackendEvent({ type: "LocationMissing", location_id: 1, path: "/test" }); - expect(onLocationMissing).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 60f72f0..25e2b6f 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,6 +1,6 @@ import { cn } from "$utils/tw"; import { AnimatePresence, motion } from "motion/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { CSSProperties, ReactNode } from "react"; export type DialogMotionPreset = "scale" | "slideUp" | "slideRight"; @@ -36,6 +36,14 @@ const DIALOG_MOTION_PRESETS: Record< const BACKDROP_FADE_MOTION = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const; const BACKDROP_FADE_TRANSITION = { duration: 0.16, ease: "easeOut" } as const; const DIALOG_SURFACE_TRANSITION = { duration: 0.2, ease: "easeOut" } as const; +const FOCUSABLE_SELECTOR = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + +function getFocusableElements(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => + !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true" + ); +} export function Dialog( { @@ -54,22 +62,66 @@ export function Dialog( }: DialogProps, ) { const motionConfig = DIALOG_MOTION_PRESETS[motionPreset]; + const panelRef = useRef(null); const handleBackdropClick = closeOnBackdrop ? onClose : undefined; useEffect(() => { - if (!isOpen || !onClose || typeof globalThis.addEventListener !== "function") { + if (!isOpen || typeof globalThis.addEventListener !== "function") { return; } - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { + const panel = panelRef.current; + if (!panel) { + return; + } + + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusable = getFocusableElements(panel); + const firstFocusable = focusable[0]; + const fallbackFocusable = panel; + (firstFocusable ?? fallbackFocusable).focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && onClose) { onClose(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const activePanel = panelRef.current; + if (!activePanel) { + return; + } + + const cycleTargets = getFocusableElements(activePanel); + if (cycleTargets.length === 0) { + event.preventDefault(); + activePanel.focus(); + return; + } + + const first = cycleTargets[0]; + const last = cycleTargets.at(-1); + const activeElement = document.activeElement; + + if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus(); } }; - globalThis.addEventListener("keydown", handleEscape); - return () => globalThis.removeEventListener("keydown", handleEscape); + globalThis.addEventListener("keydown", handleKeyDown); + return () => { + globalThis.removeEventListener("keydown", handleKeyDown); + previousFocus?.focus(); + }; }, [isOpen, onClose]); return ( @@ -95,6 +147,8 @@ export function Dialog( role="dialog" aria-label={ariaLabel} aria-modal={showBackdrop} + tabIndex={-1} + ref={panelRef} className={cn("pointer-events-auto", panelClassName)} style={panelStyle} initial={motionConfig.initial} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 43e6a32..b9783bd 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -145,6 +145,25 @@ function createEditorState( }); } +function areCustomPatternsEqual( + left: StyleCheckSettings["customPatterns"], + right: StyleCheckSettings["customPatterns"], +): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + const a = left[index]; + const b = right[index]; + if (a.text !== b.text || a.category !== b.category || a.replacement !== b.replacement) { + return false; + } + } + + return true; +} + export function Editor( { initialText = "", @@ -316,6 +335,10 @@ export function Editor( && 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, + ) ) { return; } diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index dd8c6fe..63fdfa2 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -2,13 +2,12 @@ import { Button } from "$components/Button"; import { Dialog } from "$components/Dialog"; import { useViewportTier } from "$hooks/useViewportTier"; import { FileTextIcon, SearchIcon, XIcon } from "$icons"; +import type { SearchFilters } from "$state/types"; import type { SearchHit } from "$types"; import { cn } from "$utils/tw"; import type { ChangeEventHandler, MouseEventHandler } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; -export type SearchFilters = { locations?: number[]; fileTypes?: string[]; dateRange?: { from?: Date; to?: Date } }; - type SearchPanelProps = { isOpen: boolean; query: string; @@ -67,17 +66,15 @@ function HighlightLabel({ hit }: { hit: SearchHit }) { } function SearchResult({ hit, onSelectResult }: SearchResultProps) { - { - const handleClick = useCallback(() => onSelectResult(hit), [onSelectResult, hit]); - return ( - - ); - } + const handleClick = useCallback(() => onSelectResult(hit), [onSelectResult, hit]); + return ( + + ); } type RenderedLocationsProps = { @@ -388,8 +385,8 @@ export function SearchPanel( }, [onQueryChange]); const toggleFilters: MouseEventHandler = useCallback(() => { - setShowFilters(!showFilters); - }, [showFilters]); + setShowFilters((previous) => !previous); + }, []); const activeFilterCount = useMemo( () => (filters.locations?.length ?? 0) + (filters.fileTypes?.length ?? 0) + (filters.dateRange ? 1 : 0), @@ -414,7 +411,7 @@ export function SearchPanel( const containerClassName = useMemo( () => cn( - "z-100 flex", + "z-[var(--z-modal)] flex", isCompact ? "items-stretch justify-stretch" : "items-end justify-center px-3 pb-3", "pointer-events-none", ), diff --git a/src/components/Sidebar/AddButton.tsx b/src/components/Sidebar/AddButton.tsx index 788dad7..448efb1 100644 --- a/src/components/Sidebar/AddButton.tsx +++ b/src/components/Sidebar/AddButton.tsx @@ -1,15 +1,13 @@ import { Button } from "$components/Button"; import { IconProps } from "$icons"; -import type { ComponentType, MouseEventHandler } from "react"; +import type { ComponentType } from "react"; export const AddButton = ( - { onClick, icon: Icon, title, disabled = false, handleMouseEnter, handleMouseLeave }: { + { onClick, icon: Icon, title, disabled = false }: { onClick: () => void; icon: ComponentType; title: string; disabled?: boolean; - handleMouseEnter: MouseEventHandler; - handleMouseLeave: MouseEventHandler; }, ) => ( diff --git a/src/components/Sidebar/RemoveButton.tsx b/src/components/Sidebar/RemoveButton.tsx index 565df82..46d0f86 100644 --- a/src/components/Sidebar/RemoveButton.tsx +++ b/src/components/Sidebar/RemoveButton.tsx @@ -1,7 +1,7 @@ import { Button } from "$components/Button"; import { TrashIcon } from "$icons"; import { AnimatePresence, motion } from "motion/react"; -import { type MouseEventHandler, useCallback } from "react"; +import { useCallback } from "react"; const MENU_INITIAL = { opacity: 0, y: -6, scale: 0.98 }; const MENU_ANIMATE = { opacity: 1, y: 0, scale: 1 }; @@ -9,25 +9,18 @@ const MENU_EXIT = { opacity: 0, y: -6, scale: 0.98 }; const MENU_TRANSITION = { duration: 0.14, ease: "easeOut" as const }; export function RemoveButton( - { isMenuOpen, handleRemoveClick, handleMouseEnter, handleMouseLeave }: { - isMenuOpen: boolean; - handleRemoveClick: () => void; - handleMouseEnter: MouseEventHandler; - handleMouseLeave: MouseEventHandler; - }, + { isMenuOpen, handleRemoveClick }: { isMenuOpen: boolean; handleRemoveClick: () => void }, ) { const Inner = useCallback( () => ( ), - [handleRemoveClick, handleMouseEnter, handleMouseLeave], + [handleRemoveClick], ); return ( diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 881db40..dfbcbcf 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -3,7 +3,7 @@ import { useWorkspaceController } from "$hooks/controllers/useWorkspaceControlle import { CollapseIcon, FileAddIcon, FolderAddIcon, RefreshIcon } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; -import type { ChangeEventHandler, MouseEventHandler } from "react"; +import type { ChangeEventHandler } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AddButton } from "./AddButton"; import { EmptyLocations } from "./EmptyLocations"; @@ -21,8 +21,6 @@ type SidebarActionsProps = { onRefresh: () => void; isAddDocumentDisabled: boolean; isRefreshDisabled: boolean; - handleMouseEnter: MouseEventHandler; - handleMouseLeave: MouseEventHandler; onToggleCollapse: () => void; }; @@ -40,38 +38,13 @@ const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void ); const SidebarActions = ( - { - onAddLocation, - onAddDocument, - onRefresh, - isAddDocumentDisabled, - isRefreshDisabled, - handleMouseEnter, - handleMouseLeave, - onToggleCollapse, - }: SidebarActionsProps, + { onAddLocation, onAddDocument, onRefresh, isAddDocumentDisabled, isRefreshDisabled, onToggleCollapse }: + SidebarActionsProps, ) => (
- - - + + +
); @@ -150,14 +123,6 @@ export function Sidebar({ onNewDocument }: SidebarProps) { [locationDocuments, filterText], ); - const handleMouseEnter: MouseEventHandler = useCallback((e) => { - (e.currentTarget as HTMLButtonElement).classList.add("bg-layer-hover-01", "text-icon-primary"); - }, []); - - const handleMouseLeave: MouseEventHandler = useCallback((e) => { - (e.currentTarget as HTMLButtonElement).classList.remove("bg-layer-hover-01", "text-icon-primary"); - }, []); - const handleInputChange: ChangeEventHandler = useCallback((e) => { setFilterText(e.currentTarget.value); }, [setFilterText]); @@ -184,8 +149,6 @@ export function Sidebar({ onNewDocument }: SidebarProps) { onRefresh={handleRefresh} isAddDocumentDisabled={!selectedLocationId} isRefreshDisabled={!selectedLocationId || refreshingLocationId === selectedLocationId} - handleMouseEnter={handleMouseEnter} - handleMouseLeave={handleMouseLeave} onToggleCollapse={toggleSidebarCollapsed} /> diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index 41649ae..a8c22cd 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -41,13 +41,9 @@ type LocationActionProps = { isMenuOpen: boolean; handleMenuClick: MouseEventHandler; handleRemoveClick: () => void; - handleMouseEnter: MouseEventHandler; - handleMouseLeave: MouseEventHandler; }; -const LocationActions = ( - { isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave }: LocationActionProps, -) => ( +const LocationActions = ({ isMenuOpen, handleMenuClick, handleRemoveClick }: LocationActionProps) => (
- +
); @@ -115,16 +107,6 @@ function SidebarLocationItemComponent( setShowLocationMenu((current) => current === location.id ? null : location.id); }, [location.id, setShowLocationMenu]); - const handleMouseEnter: MouseEventHandler = useCallback((e) => { - (e.currentTarget as HTMLButtonElement).classList.add("bg-support-error", "text-white"); - (e.currentTarget as HTMLButtonElement).classList.remove("text-support-error"); - }, []); - - const handleMouseLeave: MouseEventHandler = useCallback((e) => { - (e.currentTarget as HTMLButtonElement).classList.remove("bg-support-error", "text-white"); - (e.currentTarget as HTMLButtonElement).classList.add("text-support-error"); - }, []); - const onItemClick = useCallback(() => { onSelect(location.id); }, [location.id, onSelect]); @@ -133,10 +115,11 @@ function SidebarLocationItemComponent( onToggle(location.id); }, [location.id, onToggle]); - const actionProps = useMemo( - () => ({ isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave }), - [isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave], - ); + const actionProps = useMemo(() => ({ isMenuOpen, handleMenuClick, handleRemoveClick }), [ + isMenuOpen, + handleMenuClick, + handleRemoveClick, + ]); return (
diff --git a/src/hooks/controllers/useAppController.ts b/src/hooks/controllers/useAppController.ts index df41584..b4f42b7 100644 --- a/src/hooks/controllers/useAppController.ts +++ b/src/hooks/controllers/useAppController.ts @@ -13,6 +13,7 @@ import { usePreview } from "$hooks/usePreview"; import { useWorkspaceSync } from "$hooks/useWorkspaceSync"; import type { PdfExportOptions } from "$pdf/types"; import { + useCalmUiSettings, useEditorPresentationStateRaw, useLayoutChromeActions, useLayoutChromeState, @@ -51,6 +52,7 @@ export function useAppController(): AppController { useLayoutHotkeys(); const layoutChrome = useLayoutChromeState(); + const calmUiSettings = useCalmUiSettings(); const { setSidebarCollapsed } = useLayoutChromeActions(); const editorPresentation = useEditorPresentationStateRaw(); const { isFocusMode } = useViewModeState(); @@ -160,8 +162,12 @@ export function useAppController(): AppController { useSettingsSync(); const calmUiEffectiveVisibility = useMemo(() => { - return { sidebar: true, statusBar: true, tabBar: true }; - }, []); + if (!calmUiSettings.enabled || calmUiSettings.chromeTemporarilyVisible) { + return { sidebar: true, statusBar: true, tabBar: true }; + } + + return { sidebar: false, statusBar: false, tabBar: false }; + }, [calmUiSettings.chromeTemporarilyVisible, calmUiSettings.enabled]); const toolbarProps = useMemo( () => ({ diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index fc95b07..cd52cca 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -10,7 +10,7 @@ import { } from "$state/selectors"; import { useAppStore } from "$state/stores/app"; import type { SidebarRefreshReason } from "$state/types"; -import type { DocMeta, DocRef, Tab } from "$types"; +import type { DocMeta, DocRef } from "$types"; import { buildDraftRelPath, getDraftTitle } from "$utils/paths"; import { useCallback, useMemo } from "react"; @@ -73,10 +73,6 @@ export function useWorkspaceController() { })); }, [removeLocation]); - const handleSelectLocation = useCallback((locationId: number) => { - setSelectedLocation(locationId); - }, [setSelectedLocation]); - const handleSelectDocument = useCallback((locationId: number, path: string) => { const docTitle = useAppStore.getState().documents.find((doc) => doc.location_id === locationId && doc.rel_path === path @@ -87,17 +83,10 @@ export function useWorkspaceController() { openDocumentTab(docRef, title); }, [openDocumentTab]); - const handleSelectTab = useCallback((tabId: string) => { - selectTab(tabId); - }, [selectTab]); - - const handleCloseTab = useCallback((tabId: string) => { - closeTab(tabId); - }, [closeTab]); - - const handleReorderTabs = useCallback((newTabs: Tab[]) => { - reorderTabs(newTabs); - }, [reorderTabs]); + const handleSelectLocation = setSelectedLocation; + const handleSelectTab = selectTab; + const handleCloseTab = closeTab; + const handleReorderTabs = reorderTabs; const handleCreateDraftTab = useCallback((docRef: DocRef, title: string) => { openDocumentTab(docRef, title); diff --git a/src/hooks/useBackendEvents.ts b/src/hooks/useBackendEvents.ts index 9310911..9e57922 100644 --- a/src/hooks/useBackendEvents.ts +++ b/src/hooks/useBackendEvents.ts @@ -6,7 +6,6 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef, useState } from "react"; export type BackendEventState = { - events: BackendEvent[]; missingLocations: Array<{ location_id: LocationId; path: string }>; conflicts: Array<{ location_id: LocationId; rel_path: string; conflict_filename: string }>; }; @@ -15,91 +14,175 @@ export type UseBackendEventsOptions = { onLocationMissing?: (locationId: LocationId, path: string) => void; onLocationChanged?: (locationId: LocationId, oldPath: string, newPath: string) => void; onReconciliationComplete?: (checked: number, missing: LocationId[]) => void; + onDocModifiedExternally?: (docRef: { location_id: LocationId; rel_path: string }) => void; }; +const MAX_ALERT_ITEMS = 100; +const INITIAL_STATE: BackendEventState = { missingLocations: [], conflicts: [] }; + +let state: BackendEventState = INITIAL_STATE; +let sharedUnlisten: UnlistenFn | null = null; +let isStartingListener = false; +const stateSubscribers = new Set<() => void>(); +const eventSubscribers = new Set<(event: BackendEvent) => void>(); + +function limitItems(items: T[]): T[] { + if (items.length <= MAX_ALERT_ITEMS) { + return items; + } + + return items.slice(items.length - MAX_ALERT_ITEMS); +} + +function notifyStateSubscribers(): void { + for (const subscriber of stateSubscribers) { + subscriber(); + } +} + +function handleBackendEvent(payload: BackendEvent): void { + switch (payload.type) { + case "LocationMissing": { + state = { + ...state, + missingLocations: limitItems([...state.missingLocations, { + location_id: payload.location_id, + path: payload.path, + }]), + }; + notifyStateSubscribers(); + logger.warn("Location missing", { locationId: payload.location_id, path: payload.path }); + break; + } + case "ConflictDetected": { + state = { + ...state, + conflicts: limitItems([...state.conflicts, { + location_id: payload.location_id, + rel_path: payload.rel_path, + conflict_filename: payload.conflict_filename, + }]), + }; + notifyStateSubscribers(); + logger.warn("Conflict detected", { + locationId: payload.location_id, + relPath: payload.rel_path, + conflictFileName: payload.conflict_filename, + }); + break; + } + case "ReconciliationComplete": { + logger.info("Reconciliation complete", { checked: payload.checked, missingCount: payload.missing.length }); + break; + } + case "LocationChanged": { + logger.info("Location changed", { + locationId: payload.location_id, + oldPath: payload.old_path, + newPath: payload.new_path, + }); + break; + } + case "DocModifiedExternally": { + logger.info("Document modified externally", { docId: payload.doc_id }); + break; + } + case "SaveStatusChanged": { + logger.info("Save status changed", { docId: payload.doc_id, status: payload.status }); + break; + } + } + + for (const subscriber of eventSubscribers) { + subscriber(payload); + } +} + +function startSharedListener(): void { + if (sharedUnlisten || isStartingListener) { + return; + } + + isStartingListener = true; + void listen("backend-event", (event: TauriEvent) => { + handleBackendEvent(event.payload); + }).then((unlisten) => { + sharedUnlisten = unlisten; + }).catch((error) => { + logger.error("Failed to subscribe to backend events", { + message: error instanceof Error ? error.message : String(error), + }); + }).finally(() => { + isStartingListener = false; + if (stateSubscribers.size === 0 && eventSubscribers.size === 0 && sharedUnlisten) { + sharedUnlisten(); + sharedUnlisten = null; + } + }); +} + +function stopSharedListenerIfIdle(): void { + if (stateSubscribers.size > 0 || eventSubscribers.size > 0 || !sharedUnlisten) { + return; + } + + sharedUnlisten(); + sharedUnlisten = null; +} + +function subscribeState(callback: () => void): () => void { + stateSubscribers.add(callback); + startSharedListener(); + return () => { + stateSubscribers.delete(callback); + stopSharedListenerIfIdle(); + }; +} + +function subscribeEvents(callback: (event: BackendEvent) => void): () => void { + eventSubscribers.add(callback); + startSharedListener(); + return () => { + eventSubscribers.delete(callback); + stopSharedListenerIfIdle(); + }; +} + +function getSnapshot(): BackendEventState { + return state; +} + export function useBackendEvents(options: UseBackendEventsOptions = {}): BackendEventState { const optionsRef = useRef(options); optionsRef.current = options; - const [events, setEvents] = useState([]); - const [missingLocations, setMissingLocations] = useState>([]); - const [conflicts, setConflicts] = useState< - Array<{ location_id: LocationId; rel_path: string; conflict_filename: string }> - >([]); + const [snapshot, setSnapshot] = useState(() => getSnapshot()); useEffect(() => { - let unlisten: UnlistenFn | undefined; - - const setupListener = async () => { - try { - unlisten = await listen("backend-event", (event: TauriEvent) => { - const { payload } = event; - - setEvents((prev) => [...prev, payload]); - - switch (payload.type) { - case "LocationMissing": { - setMissingLocations((prev) => [...prev, { location_id: payload.location_id, path: payload.path }]); - optionsRef.current.onLocationMissing?.(payload.location_id, payload.path); - logger.warn("Location missing", { locationId: payload.location_id, path: payload.path }); - break; - } - case "ConflictDetected": { - setConflicts(( - prev, - ) => [...prev, { - location_id: payload.location_id, - rel_path: payload.rel_path, - conflict_filename: payload.conflict_filename, - }]); - logger.warn("Conflict detected", { - locationId: payload.location_id, - relPath: payload.rel_path, - conflictFileName: payload.conflict_filename, - }); - break; - } - case "ReconciliationComplete": { - optionsRef.current.onReconciliationComplete?.(payload.checked, payload.missing); - logger.info("Reconciliation complete", { - checked: payload.checked, - missingCount: payload.missing.length, - }); - break; - } - case "LocationChanged": { - optionsRef.current.onLocationChanged?.(payload.location_id, payload.old_path, payload.new_path); - logger.info("Location changed", { - locationId: payload.location_id, - oldPath: payload.old_path, - newPath: payload.new_path, - }); - break; - } - case "DocModifiedExternally": { - logger.info("Document modified externally", { docId: payload.doc_id }); - break; - } - case "SaveStatusChanged": { - logger.info("Save status changed", { docId: payload.doc_id, status: payload.status }); - break; - } - } - }); - } catch (error) { - logger.error("Failed to subscribe to backend events", { - message: error instanceof Error ? error.message : String(error), - }); - } - }; - - setupListener(); + return subscribeState(() => { + setSnapshot(getSnapshot()); + }); + }, []); - return () => { - if (unlisten) { - unlisten(); + useEffect(() => { + return subscribeEvents((payload) => { + switch (payload.type) { + case "LocationMissing": + optionsRef.current.onLocationMissing?.(payload.location_id, payload.path); + break; + case "LocationChanged": + optionsRef.current.onLocationChanged?.(payload.location_id, payload.old_path, payload.new_path); + break; + case "ReconciliationComplete": + optionsRef.current.onReconciliationComplete?.(payload.checked, payload.missing); + break; + case "DocModifiedExternally": + optionsRef.current.onDocModifiedExternally?.(payload.doc_id); + break; + default: + break; } - }; + }); }, []); - return { events, missingLocations, conflicts }; + return snapshot; } diff --git a/src/hooks/useCmdLoop.ts b/src/hooks/useCmdLoop.ts index f8c1589..1ac5b27 100644 --- a/src/hooks/useCmdLoop.ts +++ b/src/hooks/useCmdLoop.ts @@ -1,6 +1,6 @@ import type { Cmd } from "$ports"; import { runCmd } from "$ports"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; export type UpdateFn = (model: Model, msg: Msg) => [Model, Cmd]; @@ -10,35 +10,34 @@ export function useCmdLoop( isMsg: (value: unknown) => value is Msg, ): { model: Model; dispatch: (msg: Msg) => void } { const [model, setModel] = useState(initialModel); + const modelRef = useRef(initialModel); const dispatch = useCallback((msg: Msg) => { - setModel((prevModel) => { - const [nextModel, cmd] = update(prevModel, msg); + const [nextModel, cmd] = update(modelRef.current, msg); + modelRef.current = nextModel; + setModel(nextModel); - if (cmd.type === "Invoke") { - const wrappedCmd: Cmd = { - ...cmd, - onOk: (value) => { - const nextMsg = cmd.onOk(value); - if (isMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - onErr: (error) => { - const nextMsg = cmd.onErr(error); - if (isMsg(nextMsg)) { - dispatch(nextMsg); - } - }, - }; + if (cmd.type === "Invoke") { + const wrappedCmd: Cmd = { + ...cmd, + onOk: (value) => { + const nextMsg = cmd.onOk(value); + if (isMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + onErr: (error) => { + const nextMsg = cmd.onErr(error); + if (isMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + }; - void runCmd(wrappedCmd); - } else if (cmd.type !== "None") { - void runCmd(cmd); - } - - return nextModel; - }); + void runCmd(wrappedCmd); + } else if (cmd.type !== "None") { + void runCmd(cmd); + } }, [isMsg, update]); return { model, dispatch }; diff --git a/src/hooks/useWorkspaceSync.ts b/src/hooks/useWorkspaceSync.ts index a1fbe10..c73070f 100644 --- a/src/hooks/useWorkspaceSync.ts +++ b/src/hooks/useWorkspaceSync.ts @@ -1,11 +1,12 @@ import { logger } from "$logger"; -import { backendEvents, docList, locationList, runCmd, startWatch, stopWatch, SubscriptionManager } from "$ports"; +import { docList, locationList, runCmd, startWatch, stopWatch } from "$ports"; import { useWorkspaceDocumentsActions, useWorkspaceLocationsActions, useWorkspaceLocationsState, } from "$state/selectors"; import { useCallback, useEffect, useRef } from "react"; +import { useBackendEvents } from "./useBackendEvents"; export function useWorkspaceSync(): void { const { selectedLocationId } = useWorkspaceLocationsState(); @@ -81,28 +82,12 @@ export function useWorkspaceSync(): void { }; }, [selectedLocationId]); - useEffect(() => { - const manager = new SubscriptionManager(); - let cleanupFn: (() => void) | undefined; - - manager.subscribe(backendEvents((event) => { - if (event.type !== "DocModifiedExternally") { - return; - } - + useBackendEvents({ + onDocModifiedExternally: (docRef) => { const currentLocationId = selectedLocationRef.current; - if (currentLocationId && event.doc_id.location_id === currentLocationId) { + if (currentLocationId && docRef.location_id === currentLocationId) { loadDocuments(currentLocationId); } - })).then((cleanup) => { - cleanupFn = cleanup; - }).catch((error) => { - logger.error("Failed to subscribe for workspace sync events", { error }); - }); - - return () => { - cleanupFn?.(); - manager.cleanup(); - }; - }, [loadDocuments]); + }, + }); } diff --git a/src/ports/types.ts b/src/ports/types.ts index f90cdcc..e4a8fcb 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -4,7 +4,6 @@ import type { CaptureMode, CaptureSubmitInput, CaptureSubmitResult, - DocContent, DocMeta, DocRef, EditorFontFamily, @@ -12,10 +11,10 @@ import type { GlobalCaptureSettings, LocationId, MarkdownProfile, - PatternCategory, RenderResult, SaveStatus, SearchHit, + StyleCheckPattern, } from "$types"; export type EditorState = { @@ -28,14 +27,6 @@ export type EditorState = { selection_to: number | null; }; -export type EditorMsg = - | { type: "EditorChanged"; text: string } - | { type: "SaveRequested" } - | { type: "SaveFinished"; success: boolean; error?: AppError } - | { type: "DocOpened"; doc: DocContent } - | { type: "CursorMoved"; line: number; column: number } - | { type: "SelectionChanged"; from: number; to: number | null }; - export type SaveResult = { success: boolean; new_meta: DocMeta | null; conflict_detected: boolean }; export type UiLayoutSettings = { @@ -53,8 +44,6 @@ export type UiLayoutSettings = { focus_dimming_mode: FocusDimmingMode; }; -export type StyleCheckPattern = { text: string; category: PatternCategory; replacement?: string }; - export type StyleCheckCategorySettings = { filler: boolean; redundancy: boolean; cliche: boolean }; export type PersistedStyleCheckSettings = { diff --git a/src/state/stores/app.ts b/src/state/stores/app.ts index b3efd89..068696b 100644 --- a/src/state/stores/app.ts +++ b/src/state/stores/app.ts @@ -29,10 +29,20 @@ import type { import type { GlobalCaptureSettings, Tab } from "$types"; import { create, type StateCreator } from "zustand"; -let nextTabId = 1; - function generateTabId(): string { - return `tab-${nextTabId++}`; + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function getInitialTheme(): "dark" | "light" { + if (typeof globalThis.matchMedia === "function") { + return globalThis.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + return "dark"; } const DEFAULT_GLOBAL_CAPTURE_SETTINGS: GlobalCaptureSettings = { @@ -63,7 +73,7 @@ const getInitialEditorPresentationState = (): EditorPresentationState => ({ syntaxHighlightingEnabled: true, editorFontSize: 16, editorFontFamily: "IBM Plex Mono", - theme: "dark", + theme: getInitialTheme(), }); const getInitialViewModeState = (): ViewModeState => ({ @@ -466,8 +476,6 @@ export const useAppStore = create()((...params) => ({ })); export function resetAppStore(): void { - nextTabId = 1; - useAppStore.setState({ ...getInitialLayoutState(), ...getInitialWorkspaceState(), diff --git a/src/usePorts.ts b/src/usePorts.ts deleted file mode 100644 index 0b87712..0000000 --- a/src/usePorts.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Cmd } from "$ports"; -import type { AppError } from "$types"; -import { useCallback, useState } from "react"; -import { runCmd } from "./ports"; - -type UsePortsState = { data: T | null; error: AppError | null; loading: boolean }; - -type UsePortsReturn = UsePortsState & { execute: (cmd: Cmd) => Promise; reset: () => void }; - -export function usePorts(): UsePortsReturn { - const [state, setState] = useState>({ data: null, error: null, loading: false }); - - const execute = useCallback(async (cmd: Cmd) => { - if (cmd.type === "None") { - return; - } - - setState((prev) => ({ ...prev, loading: true, error: null })); - - try { - await runCmd(cmd); - setState((prev) => ({ ...prev, loading: false })); - } catch (error) { - setState({ - data: null, - error: { code: "IO_ERROR", message: error instanceof Error ? error.message : String(error) }, - loading: false, - }); - } - }, []); - - const reset = useCallback(() => { - setState({ data: null, error: null, loading: false }); - }, []); - - return { ...state, execute, reset }; -} -- 2.51.2