diff --git a/AUDIT_LOG.md b/AUDIT_LOG.md index a582c02..350a68d 100644 --- a/AUDIT_LOG.md +++ b/AUDIT_LOG.md @@ -2,6 +2,22 @@ This log tracks all significant changes, updates, and versions in the PaperCache project. +## 2026-06-29 (Code Quality Cleanup) +**Change:** refactor: code quality cleanup — dead code, boilerplate, types, constants, AI comments; fix: address PR review findings — listener leak, type contracts, dead ref, stale guard, cfg scope, shortcut loop, timer constant + +**Details/Why:** +1. **Dead Code Removal**: Removed `resumeTimer` no-op stub from useTimerStore; removed `onSwipeGesture` (ignored callback) from api.ts and types; removed `themePreset`/`setThemePreset` from useAppStore (duplicated in useSettingsStore, all consumers used the latter); removed `prevNotesRef` from useReminders (assigned but never read). +2. **Boilerplate Consolidation**: `useAppStore.ts` — consolidated 9 setters into `booleanSetter`/`simpleSetter` helpers; `api.ts` — extracted 5×8-line identical listener patterns into shared `onEvent` helper; `KeybindsModal.tsx` — replaced 9 parallel `useState`/`getShortcut` calls with data-driven config array; `useSettingsStore.ts` — removed 11 redundant individual setters (use `setSettings` instead). +3. **Rust Fixes**: Fixed clippy `needless_borrows_for_generic_args` in `notifications.rs`; added doc-commented `[lints.rust] unexpected_cfgs = "allow"` for objc crate macro warnings. +4. **Type Safety**: `any` → typed `GraphControls` interface in GraphView; `Promise` → properly typed `openAIChat` response; replaced unsafe `as` casts with wrapper functions; `pauseShortcuts`/`resumeShortcuts` changed to return `Promise`. +5. **Magic Numbers → Named Constants**: Extracted ~25 magic numbers across the codebase (z-indices, timeouts, force params, debounce intervals, canvas dimensions, etc.). +6. **Comment Cleanup**: Removed ~15 pedagogical/AI-generated comments. +7. **PR Review Fixes**: Fixed `onEvent` listener leak (added `disposed` flag); fixed stale-token guard in `useReminders` to gate before backend call; fixed `openAIChat` response validation for missing content; removed dead `searchInputRef`/`useEffect` in App.tsx; narrowed `unexpected_cfgs` suppression; made KeybindsModal global shortcut loop data-driven via config; aligned initial timer tick constant in TimersPage. + +**Files changed:** `src/store/useTimerStore.ts`, `src/api.ts`, `src/types.d.ts`, `src/setupTests.ts`, `src/store/useAppStore.ts`, `src/store/useAppStore.test.ts`, `src/store/useSettingsStore.ts`, `src/hooks/useReminders.ts`, `src/components/KeybindsModal.tsx`, `src/components/TimersPage.tsx`, `src/GraphView.tsx`, `src/App.tsx`, `src/lib/editor/extensions.ts`, `src/lib/editor/MathEvaluator.ts`, `src/lib/editor/VariableScope.ts`, `src/components/Editor.tsx`, `src-tauri/src/commands/notifications.rs`, `src-tauri/Cargo.toml`, `src-tauri/src/lib.rs`, `src-tauri/src/macos.rs`, `CHANGELOG.md`, `AUDIT_LOG.md`. + +--- + ## 2026-06-28 (v0.5.6 Release: Keybinds Modal, Shortcut Mappings, Timer Auto-Delete, and Graph Link Refinement) **Change:** chore(release): bump version to 0.5.6; feat(shortcuts): add dedicated keybinds settings modal and update global hotkeys (`Cmd+R` for tasks, `Cmd+T` for timers); feat(timers): auto-delete expired timers after 5 seconds; feat(graph): support standard markdown links and wikilinks diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5a091..52cff4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Updated Default Shortcut Mappings**: Shifted the Reminders/Tasks view shortcut to `Cmd+R` and Timers panel shortcut to `Cmd+T`. - **Timer Auto-Deletion**: Expired countdown timers are now automatically removed from the active list 5 seconds after completing, keeping the UI clean. Timer completion notifications and auto-cleanup now function globally even when the Timers panel is closed. - **Enhanced Graph View Link Parsing**: Extended 3D Graph View link detection to support standard markdown links (`[Note](Note.md)`) and wikilinks (`[[Note]]`) alongside the existing `/file` syntax, and added z-axis forces for improved 3D layout stability. +- **Code Quality Cleanup**: Consolidated repetitive boilerplate across stores, event listeners, and UI components; extracted ~25 magic numbers into named constants; removed dead code and pedagogical AI-style comments. +- **Improved Type Safety**: Replaced `any` with typed interfaces in GraphView; properly typed `openAIChat` API response; aligned async method return types across bridge API. +- **Rust Lint Cleanup**: Fixed clippy warnings in notifications.rs; documented suppressions for legacy objc crate macro warnings. ## [v0.5.5] - 2026-06-27 diff --git a/README.md b/README.md index d001e80..df9eac9 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,17 @@ Built with Tauri, Rust, React, TypeScript, and Vite. ## Shortcuts +All shortcuts are fully customizable via Settings → Keybinds (`Cmd+Shift+S`). Defaults: + | Shortcut | Action | | ------------- | ---------------------------------------- | | `Cmd+Shift+C` | Toggle visibility (global, configurable) | | `Cmd+Shift+N` | New note (global, configurable) | -| `Cmd+Shift+S` | Open settings panel | +| `Cmd+Shift+S` | Open keybinds settings modal | | `Cmd+N` | New note (in-app) | | `Cmd+/` | Open shortcuts reference | -| `Cmd+T` | Open Tasks page | +| `Cmd+R` | Open Tasks & Reminders page | +| `Cmd+T` | Open Timers page | | `Cmd+K` | Main action menu | | `Cmd+P` | Search notes | | `Cmd+G` | Graph view | diff --git a/features.md b/features.md index 1274c27..a75023b 100644 --- a/features.md +++ b/features.md @@ -11,7 +11,7 @@ This document outlines every feature available in the PaperCache codebase, organ - **Date & Time Formats**: Highlights standard date (`DD-MM-YYYY` or `YYYY-MM-DD`) and time (`HH:MM` or `HH:MM:SS`) formats into clean, distinct pills. - **Slash Command Autosuggest**: Type `/` to trigger an inline ghost-text autosuggest for commands like `/check`, `/task`, `/ai`, or `/ctx`. Press `Tab` to instantly complete the command without breaking your typing flow. - **Interactive Checkboxes**: Type `/check` to create an interactive checkbox widget. Clicking it changes it to `/checked` and visually strikes through the text on that line! -- **Tasks & Reminders**: Type `/task` to create a task widget. Add a space followed by `@` and a time (like `1d2h`, `tmrw`, or a specific date `YYYY-MM-DD HH:MM`) to set a due date. Press `Cmd+T` (or `Ctrl+T`) to open the Tasks Page, which tracks all tasks, calculates due times, and highlights overdue tasks in red. +- **Tasks & Reminders**: Type `/task` to create a task widget. Add a space followed by `@` and a time (like `1d2h`, `tmrw`, or a specific date `YYYY-MM-DD HH:MM`) to set a due date. Press `Cmd+T` (or `Ctrl+T`) to open the Tasks Page, which tracks all tasks, calculates due times, and highlights overdue tasks in red. Expired timers are automatically removed from the list after 5 seconds to keep the UI clean. - **Customizable Theming & Fonts**: Customize fonts, text colors, background colors, background images, and individual highlight colors for variables, AI, and math. Supports full dark mode (`grid-dark`, `blueprint`). ## Math, Variables, and Calculations @@ -28,6 +28,7 @@ This document outlines every feature available in the PaperCache codebase, organ - **Interactive Graph View** (`Cmd+G`): An interactive 2D knowledge graph rendered with Three.js WebGL. Nodes are clean flat circles with always-visible labels, edges are colored by opacity. Features: - **Folder Clustering**: Notes in the same folder are gently attracted toward a shared centroid, creating subtle visual groupings. - **Cmd+F Fuzzy Search**: Press `Cmd+F` inside graph view to fuzzy-search note names. Navigate with arrow keys, press Enter to fly the camera directly to the matched node. + - **Multi-Format Link Detection**: Automatically detects connections via standard markdown links (`[Note](Note.md)`), wikilinks (`[[Note]]`), and `/file Note` syntax. - **Drag to Rearrange**: Nodes can be dragged freely; positions are cached and restored across graph sessions. - **Smooth Fade-in**: The graph overlay animates in with a 250ms fade. - **Lazy-Loaded**: The Three.js bundle (~1.3 MB) loads only when the graph is first opened, keeping startup fast. @@ -49,7 +50,7 @@ This document outlines every feature available in the PaperCache codebase, organ - **Stealth / Background Mode**: Click away or lose focus, and the app instantly hides itself (macOS) or after a brief debounce (Windows/Linux — prevents accidental hide when dragging the title bar). On macOS, it runs as an "accessory" and hides its dock icon completely, acting like a true floating utility. - **Intelligent Multi-Monitor Support**: When summoning the app via its global hotkey, it detects the active screen your mouse is currently on and brings the window instantly to that specific screen's workspace. - **System Tray Icon**: A minimal system tray icon for toggling visibility or quitting the app cleanly, adapting to the user's OS theme (light/dark). -- **Global Hotkeys**: +- **Global Hotkeys**: All shortcuts are fully customizable via Settings → Keybinds (`Cmd+Shift+S`). The keybinds modal lets you remap every action with live recording. Defaults include: - `Cmd+Shift+N` (configurable): Spawn a new note from anywhere. If the app is already open, creates the note without hiding. - `Cmd+Shift+C` (configurable): Toggle PaperCache visibility from anywhere on your OS. - **State Memory**: Memorizes precise window coordinates, dimensions, and zoom levels across launches to persist workspace state. diff --git a/package-lock.json b/package-lock.json index aa638f5..dc70159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "papercache", - "version": "0.5.3", + "version": "0.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papercache", - "version": "0.5.3", + "version": "0.5.6", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a2daf1..06468e7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "papercache" -version = "0.5.5" +version = "0.5.6" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 933fff1..693846e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,3 +32,12 @@ tauri-plugin-notification = "2.0.0-rc.5" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.25" objc = "0.2" + +# The objc v0.2 crate macros use cfg(cargo-clippy) which is no longer recognized, +# producing unexpected_cfgs warnings from external macro expansions that cannot +# be suppressed per-function or per-module (the span originates in objc crate code). +[lints.rust] +unexpected_cfgs = "allow" + + + diff --git a/src-tauri/src/commands/notifications.rs b/src-tauri/src/commands/notifications.rs index a508f2e..4d207e9 100644 --- a/src-tauri/src/commands/notifications.rs +++ b/src-tauri/src/commands/notifications.rs @@ -103,7 +103,7 @@ pub async fn schedule_timer( .notification() .builder() .title("PaperCache Timer") - .body(&format!("⏱ Timer finished: {}", label)) + .body(format!("⏱ Timer finished: {}", label)) .show(); let _ = app_clone.emit("timer-complete", &id_clone); }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56ee5bf..1fc9fad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,11 @@ mod commands; mod macos; mod tray; +#[allow(dead_code)] +const FOCUS_LOSS_DEBOUNCE_MS: u64 = 200; +#[allow(dead_code)] +const WINDOW_STATE_RESTORE_DELAY_MS: u64 = 300; + use commands::shortcuts::GlobalShortcutState; use commands::notifications::NotificationState; @@ -87,7 +92,7 @@ pub fn run() { let dialog_open = is_dialog_open.clone(); std::thread::spawn(move || { std::thread::sleep( - std::time::Duration::from_millis(200), + std::time::Duration::from_millis(FOCUS_LOSS_DEBOUNCE_MS), ); if g2.load(Ordering::SeqCst) == gen_at_spawn && !dialog_open.load(Ordering::SeqCst) @@ -109,7 +114,7 @@ pub fn run() { // Plugin's on_window_ready fires too early for available_monitors() on macOS. let win = window.clone(); std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); + std::thread::sleep(std::time::Duration::from_millis(WINDOW_STATE_RESTORE_DELAY_MS)); let _ = win.clone().run_on_main_thread(move || { let _ = win.restore_state(StateFlags::POSITION | StateFlags::SIZE); if let Ok(app_dir) = win.app_handle().path().app_config_dir() { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index ffb56bd..68641c3 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1,5 +1,3 @@ -#![allow(unexpected_cfgs)] - #[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter}; diff --git a/src/App.tsx b/src/App.tsx index d87957e..f788959 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,11 @@ import { NoteTitleBar } from './components/NoteTitleBar' import { Editor, type EditorRef } from './components/Editor' import Settings from './Settings' +const TOAST_TIMEOUT_MS = 5000 +const MODAL_Z_INDEX = 9999 +const KEYBINDS_Z_INDEX = 10000 +const TOAST_Z_INDEX = 99999 + function App() { const notes = useAppStore((state) => state.notes) const setNotes = useAppStore((state) => state.setNotes) @@ -36,7 +41,6 @@ function App() { const setShowTimersView = useAppStore((state) => state.setShowTimersView) const toasts = useAppStore((state) => state.toasts) const removeToast = useAppStore((state) => state.removeToast) - const showNoteSearch = useAppStore((state) => state.showNoteSearch) const setShowMainActionMenu = useAppStore((state) => state.setShowMainActionMenu) const showSettingsModal = useAppStore((state) => state.showSettingsModal) const setShowSettingsModal = useAppStore((state) => state.setShowSettingsModal) @@ -48,9 +52,6 @@ function App() { const editorRef = useRef(null) - const searchInputRef = useRef(null) - - // Custom Hooks useNoteStorage() useVariables() useReminders() @@ -63,7 +64,6 @@ function App() { useAppStore.getState().setIsHyprland(isHyp) }) - // Show a toast before the app auto-restarts for an update const disposeUpdateReady = window.electronAPI.onUpdateReady(() => { useAppStore.getState().addToast({ message: '✨ PaperCache updated — restarting in 3 seconds…', @@ -94,13 +94,11 @@ function App() { } }, []) - // Auto-dismiss toasts after 5 seconds const toastTimersRef = useRef>>(new Map()) useEffect(() => { const timers = toastTimersRef.current const currentIds = new Set(toasts.map((t) => t.id)) - // Clear timers for removed toasts for (const [id, timer] of timers) { if (!currentIds.has(id)) { clearTimeout(timer) @@ -108,25 +106,16 @@ function App() { } } - // Set timers for new toasts for (const toast of toasts) { if (!timers.has(toast.id)) { timers.set( toast.id, - setTimeout(() => removeToast(toast.id), 5000) + setTimeout(() => removeToast(toast.id), TOAST_TIMEOUT_MS) ) } } }, [toasts, removeToast]) - useEffect(() => { - if (showNoteSearch && searchInputRef.current) { - setTimeout(() => { - searchInputRef.current?.focus() - }, 50) - } - }, [showNoteSearch]) - useEffect(() => { async function checkVersion() { if (notes.length === 0) return @@ -205,13 +194,11 @@ function App() { const note = currentNotes[idx] const newContent = note.content.slice(0, from) + insert + note.content.slice(to) - // Side-effects outside of state updater window.electronAPI.saveNote(note.id, newContent) if (idx === currentNoteIndex) { editorRef.current?.dispatch({ changes: { from, to, insert } }) } - // Pure state update setNotes((prevNotes) => prevNotes.map((n) => (n.id === noteId ? { ...n, content: newContent } : n)) ) @@ -257,7 +244,7 @@ function App() { bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.75)', backdropFilter: 'blur(5px)', - zIndex: 9999, + zIndex: MODAL_Z_INDEX, overflow: 'auto', }} > @@ -276,7 +263,7 @@ function App() { bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.75)', backdropFilter: 'blur(5px)', - zIndex: 10000, + zIndex: KEYBINDS_Z_INDEX, overflow: 'auto', }} > @@ -294,7 +281,7 @@ function App() { display: 'flex', flexDirection: 'column', gap: 8, - zIndex: 99999, + zIndex: TOAST_Z_INDEX, }} > {toasts.map((toast) => ( diff --git a/src/GraphView.tsx b/src/GraphView.tsx index 68080fc..135f592 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -6,6 +6,31 @@ import { getFolderColor } from './utils' const ForceGraph3D = lazy(() => import('react-force-graph-3d')) +const FOLDER_CENTROID_RADIUS = 60 +const CAMERA_Z_POSITION = 500 +const ZOOM_TO_FIT_DURATION_MS = 400 +const ZOOM_TO_FIT_PADDING = 50 +const ZOOM_TO_FIT_DELAY_MS = 300 +const FORCE_CHARGE_STRENGTH = -120 +const COLLISION_RADIUS = 22 +const FOLDER_ATTRACTION_STRENGTH = 0.008 +const MAX_ATTEMPTS = 20 +const ATTEMPT_INTERVAL_MS = 50 +const GRAPH_Z_INDEX = 1000 +const LABEL_CANVAS_WIDTH = 256 +const LABEL_CANVAS_HEIGHT = 64 +const LABEL_SPRITE_SCALE_X = 36 +const LABEL_SPRITE_SCALE_Y = 9 +const LABEL_SPRITE_SCALE_Z = 1 +const NODE_REL_SIZE = 6 +const LINK_WIDTH = 1.5 +const LINK_OPACITY = 0.6 +const FOCUS_CAMERA_Z = 120 +const FOCUS_ANIMATION_DURATION_MS = 400 +const NODE_RADIUS = 12 +const LABEL_FONT_SIZE = 20 +const MAX_NAME_LENGTH = 20 + const nodePositionsCache = new Map() interface GraphViewProps { @@ -36,7 +61,7 @@ function buildFolderCentroids(folderNames: string[]): Map() const n = folderNames.length if (n === 0) return centroids - const radius = 60 + const radius = FOLDER_CENTROID_RADIUS folderNames.forEach((folder, i) => { const angle = (2 * Math.PI * i) / n - Math.PI / 2 centroids.set(folder, { @@ -67,17 +92,27 @@ export default function GraphView({ const draggedNodesRef = useRef>(new Set()) const graphDataRef = useRef<{ nodes: GraphNode[]; links: GraphLink[] }>({ nodes: [], links: [] }) + interface GraphControls { + enableRotate: boolean + enablePan: boolean + enableZoom: boolean + mouseButtons: Record + touches: Record + zoomSpeed: number + panSpeed: number + update: () => void + } + useEffect(() => { let raf: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let ctrls: any = null + let ctrls: GraphControls | null = null const setup = () => { const fg = fgRef.current if (!fg || typeof fg.controls !== 'function') { raf = requestAnimationFrame(setup) return } - ctrls = fg.controls() + ctrls = fg.controls() as GraphControls if (!ctrls) { raf = requestAnimationFrame(setup) return @@ -91,19 +126,18 @@ export default function GraphView({ ctrls.panSpeed = 0.15 ctrls.update() if (typeof fg.cameraPosition === 'function') { - fg.cameraPosition({ x: 0, y: 0, z: 500 }) + fg.cameraPosition({ x: 0, y: 0, z: CAMERA_Z_POSITION }) } setTimeout(() => { - if (fg && typeof fg.zoomToFit === 'function') fg.zoomToFit(400, 50) - }, 300) + if (fg && typeof fg.zoomToFit === 'function') + fg.zoomToFit(ZOOM_TO_FIT_DURATION_MS, ZOOM_TO_FIT_PADDING) + }, ZOOM_TO_FIT_DELAY_MS) } raf = requestAnimationFrame(setup) return () => cancelAnimationFrame(raf) }, []) useEffect(() => { - // Snapshot ref at effect-run time so the cleanup reads a stable value - // (avoids the react-hooks/exhaustive-deps stale-ref warning) const fg = fgRef.current return () => { const data = fg && typeof fg.graphData === 'function' ? fg.graphData() : graphDataRef.current @@ -143,7 +177,6 @@ export default function GraphView({ notes.forEach((note) => { const targets = new Set() - // 1. Match ](/file ) const reFile = /\]\(\/file\s+([^)]+)\)/g let match while ((match = reFile.exec(note.content)) !== null) { @@ -152,7 +185,6 @@ export default function GraphView({ targets.add(targetId) } - // 2. Match ](.md) const reMd = /\]\(([^)]+\.md)\)/g while ((match = reMd.exec(note.content)) !== null) { let targetId = match[1].trim().replace(/\\/g, '/') @@ -161,7 +193,6 @@ export default function GraphView({ targets.add(targetId) } - // 3. Match [[]] const reWiki = /\[\[([^\]]+)\]\]/g while ((match = reWiki.exec(note.content)) !== null) { let targetId = match[1].split('|')[0].trim().replace(/\\/g, '/') @@ -191,8 +222,8 @@ export default function GraphView({ const fg = fgRef.current if (!fg || typeof fg.d3Force !== 'function') { attempts++ - if (attempts <= 20) { - timeoutId = window.setTimeout(attemptForceSetup, 50) + if (attempts <= MAX_ATTEMPTS) { + timeoutId = window.setTimeout(attemptForceSetup, ATTEMPT_INTERVAL_MS) } return } @@ -200,8 +231,8 @@ export default function GraphView({ const folders = Array.from(new Set(graphData.nodes.map((n) => n.folder).filter(Boolean))) const centroids = buildFolderCentroids(folders) - fg.d3Force('centerX', d3.forceX<GraphNode>(0).strength(0.008)) - fg.d3Force('centerY', d3.forceY<GraphNode>(0).strength(0.008)) + fg.d3Force('centerX', d3.forceX<GraphNode>(0).strength(FOLDER_ATTRACTION_STRENGTH)) + fg.d3Force('centerY', d3.forceY<GraphNode>(0).strength(FOLDER_ATTRACTION_STRENGTH)) fg.d3Force( 'folderX', d3 @@ -209,7 +240,9 @@ export default function GraphView({ const c = centroids.get(node.folder) return c ? c.cx : 0 }) - .strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0)) + .strength((node) => + node.folder && !draggedNodesRef.current.has(node.id) ? FOLDER_ATTRACTION_STRENGTH : 0 + ) ) fg.d3Force( 'folderY', @@ -218,16 +251,18 @@ export default function GraphView({ const c = centroids.get(node.folder) return c ? c.cy : 0 }) - .strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0)) + .strength((node) => + node.folder && !draggedNodesRef.current.has(node.id) ? FOLDER_ATTRACTION_STRENGTH : 0 + ) ) - fg.d3Force('charge')?.strength(-120) - fg.d3Force('collision', d3.forceCollide<GraphNode>(22)) + fg.d3Force('charge')?.strength(FORCE_CHARGE_STRENGTH) + fg.d3Force('collision', d3.forceCollide<GraphNode>(COLLISION_RADIUS)) if (typeof fg.d3ReheatSimulation === 'function') { fg.d3ReheatSimulation() } } - timeoutId = window.setTimeout(attemptForceSetup, 50) + timeoutId = window.setTimeout(attemptForceSetup, ATTEMPT_INTERVAL_MS) return () => { if (timeoutId !== null) clearTimeout(timeoutId) } @@ -256,7 +291,11 @@ export default function GraphView({ if (!fg || typeof fg.graphData !== 'function' || typeof fg.cameraPosition !== 'function') return const node = fg.graphData()?.nodes?.find((n: GraphNode) => n.id === nodeId) if (!node || node.x == null || node.y == null) return - fg.cameraPosition({ x: node.x, y: node.y, z: 120 }, { x: node.x, y: node.y, z: 0 }, 400) + fg.cameraPosition( + { x: node.x, y: node.y, z: FOCUS_CAMERA_Z }, + { x: node.x, y: node.y, z: 0 }, + FOCUS_ANIMATION_DURATION_MS + ) }, []) const nodeThreeObject = useCallback( @@ -264,23 +303,26 @@ export default function GraphView({ const color = node.folder ? getFolderColor(node.folder) : accentColor const group = new THREE.Group() - const geometry = new THREE.CircleGeometry(12, 32) + const geometry = new THREE.CircleGeometry(NODE_RADIUS, 32) const material = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }) const circle = new THREE.Mesh(geometry, material) circle.position.z = 1 group.add(circle) const canvas = document.createElement('canvas') - canvas.width = 256 - canvas.height = 64 + canvas.width = LABEL_CANVAS_WIDTH + canvas.height = LABEL_CANVAS_HEIGHT const ctx = canvas.getContext('2d')! ctx.clearRect(0, 0, 256, 64) - const displayName = node.name.length > 20 ? node.name.slice(0, 17) + '…' : node.name + const displayName = + node.name.length > MAX_NAME_LENGTH + ? node.name.slice(0, MAX_NAME_LENGTH - 3) + '…' + : node.name ctx.fillStyle = textColor - ctx.font = 'bold 20px sans-serif' + ctx.font = `bold ${LABEL_FONT_SIZE}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' - ctx.fillText(displayName, 128, 32) + ctx.fillText(displayName, LABEL_CANVAS_WIDTH / 2, LABEL_CANVAS_HEIGHT / 2) const tex = new THREE.CanvasTexture(canvas) const labelMat = new THREE.SpriteMaterial({ map: tex, @@ -289,7 +331,7 @@ export default function GraphView({ depthTest: false, }) const label = new THREE.Sprite(labelMat) - label.scale.set(36, 9, 1) + label.scale.set(LABEL_SPRITE_SCALE_X, LABEL_SPRITE_SCALE_Y, LABEL_SPRITE_SCALE_Z) label.position.set(0, -15, 0) label.renderOrder = 2 group.add(label) @@ -360,7 +402,7 @@ export default function GraphView({ right: 0, bottom: 0, backgroundColor: bgColor, - zIndex: 1000, + zIndex: GRAPH_Z_INDEX, display: 'flex', flexDirection: 'column', fontFamily: 'inherit', @@ -479,12 +521,12 @@ export default function GraphView({ } linkColor={() => `${textColor}55`} backgroundColor={bgColor} - onNodeClick={handleNodeClick as (node: object) => void} - onNodeDragEnd={handleNodeDragEnd as (node: object) => void} + onNodeClick={(node: object) => handleNodeClick(node as GraphNode)} + onNodeDragEnd={(node: object) => handleNodeDragEnd(node as GraphNode)} nodeThreeObject={nodeThreeObject} - nodeRelSize={6} - linkWidth={1.5} - linkOpacity={0.6} + nodeRelSize={NODE_REL_SIZE} + linkWidth={LINK_WIDTH} + linkOpacity={LINK_OPACITY} enableNodeDrag={true} enableNavigationControls={true} showNavInfo={false} diff --git a/src/api.ts b/src/api.ts index 3795ad8..4132ead 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,6 +2,22 @@ import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import type { ElectronAPI, ReminderPayload } from './types' +const onEvent = (name: string, callback: () => void) => { + let unlisten: (() => void) | undefined + let disposed = false + listen(name, () => callback()).then((fn) => { + if (disposed) { + fn() + return + } + unlisten = fn + }) + return () => { + disposed = true + unlisten?.() + } +} + export const tauriApi: ElectronAPI = { // Implemented Phase 2 Commands getNotes: async () => { @@ -35,60 +51,17 @@ export const tauriApi: ElectronAPI = { checkForUpdates: () => invoke('check_for_updates'), restoreWindowState: () => invoke('restore_window_state'), isHyprland: () => invoke('is_hyprland'), - onSwipeGesture: () => { - return () => {} - }, getLaunchAtStartup: () => invoke('get_launch_at_startup'), setLaunchAtStartup: (value) => invoke('set_launch_at_startup', { enabled: value }), updateGlobalShortcut: (action, oldShortcut, newShortcut) => invoke('update_global_shortcut', { action, oldShortcut, newShortcut }), - onTriggerNewNote: (callback) => { - let unlisten: (() => void) | undefined - listen('trigger-new-note', () => callback()).then((fn) => { - unlisten = fn - }) - return () => { - if (unlisten) unlisten() - } - }, - onTriggerTasks: (callback) => { - let unlisten: (() => void) | undefined - listen('trigger-tasks', () => callback()).then((fn) => { - unlisten = fn - }) - return () => { - if (unlisten) unlisten() - } - }, + onTriggerNewNote: (callback) => onEvent('trigger-new-note', callback), + onTriggerTasks: (callback) => onEvent('trigger-tasks', callback), safeStorageEncrypt: (val) => invoke('safe_storage_encrypt', { val }), safeStorageDecrypt: (val) => invoke('safe_storage_decrypt', { val }), - onPowerSuspend: (callback) => { - let unlisten: (() => void) | undefined - listen('power:suspend', () => callback()).then((fn) => { - unlisten = fn - }) - return () => { - if (unlisten) unlisten() - } - }, - onPowerResume: (callback) => { - let unlisten: (() => void) | undefined - listen('power:resume', () => callback()).then((fn) => { - unlisten = fn - }) - return () => { - if (unlisten) unlisten() - } - }, - pauseShortcuts: () => invoke('pause_shortcuts') as unknown as void, - resumeShortcuts: () => invoke('resume_shortcuts') as unknown as void, - onUpdateReady: (callback) => { - let unlisten: (() => void) | undefined - listen('update-ready', () => callback()).then((fn) => { - unlisten = fn - }) - return () => { - if (unlisten) unlisten() - } - }, + onPowerSuspend: (callback) => onEvent('power:suspend', callback), + onPowerResume: (callback) => onEvent('power:resume', callback), + pauseShortcuts: () => invoke('pause_shortcuts'), + resumeShortcuts: () => invoke('resume_shortcuts'), + onUpdateReady: (callback) => onEvent('update-ready', callback), } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 5933f11..dbf62db 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -7,6 +7,8 @@ import { useSettingsStore } from '../store/useSettingsStore' import { useEditorExtensions } from '../lib/editor/extensions' import { MathEvaluator } from '../lib/editor/MathEvaluator' import { type TransactionSpec } from '@codemirror/state' +const SAVE_DEBOUNCE_MS = 500 + import { EditorView } from '@codemirror/view' export interface EditorRef { @@ -54,7 +56,7 @@ export const Editor = forwardRef<EditorRef>((_props, ref) => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = setTimeout(() => { window.electronAPI.saveNote(note.id, val) - }, 500) + }, SAVE_DEBOUNCE_MS) } return updatedNotes }) diff --git a/src/components/KeybindsModal.tsx b/src/components/KeybindsModal.tsx index 3c2a30d..b768627 100644 --- a/src/components/KeybindsModal.tsx +++ b/src/components/KeybindsModal.tsx @@ -7,46 +7,111 @@ interface KeybindsModalProps { onClose: () => void } +interface ShortcutConfig { + key: string + label: string + storageKey: string + defaultKey: string + section: 'global' | 'app' + action?: string + oldShortcutStorageKey?: string +} + export function KeybindsModal({ onClose }: KeybindsModalProps) { const isHyprland = useAppStore((state) => state.isHyprland) const defaultMod = isHyprland ? 'Alt' : 'CommandOrControl' - // Global Shortcuts - const [shortcutNewNote, setShortcutNewNote] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE, `${defaultMod}+Shift+N`) - ) - const [shortcutToggle, setShortcutToggle] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_TOGGLE, `${defaultMod}+Shift+C`) - ) - - // In-App Shortcuts - const [shortcutTasks, setShortcutTasks] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_TASKS, `${defaultMod}+R`) - ) - const [shortcutTimers, setShortcutTimers] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_TIMERS, `${defaultMod}+T`) - ) - const [shortcutSearch, setShortcutSearch] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_SEARCH, `${defaultMod}+P`) - ) - const [shortcutGraph, setShortcutGraph] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_GRAPH, `${defaultMod}+G`) - ) - const [shortcutActionMenu, setShortcutActionMenu] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_ACTION_MENU, `${defaultMod}+K`) - ) - const [shortcutExport, setShortcutExport] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_EXPORT, `${defaultMod}+E`) - ) - const [shortcutRef, setShortcutRef] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_REF, `${defaultMod}+/`) - ) - const [shortcutSettings, setShortcutSettings] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_SETTINGS, `${defaultMod}+Shift+S`) - ) - const [shortcutNewNoteInApp, setShortcutNewNoteInApp] = useState( - getShortcut(SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, `${defaultMod}+N`) - ) + const shortcuts: ShortcutConfig[] = [ + { + key: 'shortcutToggle', + label: 'Toggle App Visibility', + storageKey: SETTINGS_KEYS.SHORTCUT_TOGGLE, + defaultKey: `${defaultMod}+Shift+C`, + section: 'global', + action: 'toggle', + oldShortcutStorageKey: 'papercache-shortcut-toggle', + }, + { + key: 'shortcutNewNote', + label: 'New Note (Global)', + storageKey: SETTINGS_KEYS.SHORTCUT_NEWNOTE, + defaultKey: `${defaultMod}+Shift+N`, + section: 'global', + action: 'new-note', + oldShortcutStorageKey: 'papercache-shortcut-newnote', + }, + { + key: 'shortcutTasks', + label: 'Open Reminders / Tasks', + storageKey: SETTINGS_KEYS.SHORTCUT_TASKS, + defaultKey: `${defaultMod}+R`, + section: 'app', + }, + { + key: 'shortcutTimers', + label: 'Open Timers Panel', + storageKey: SETTINGS_KEYS.SHORTCUT_TIMERS, + defaultKey: `${defaultMod}+T`, + section: 'app', + }, + { + key: 'shortcutNewNoteInApp', + label: 'New Note (In-App)', + storageKey: SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, + defaultKey: `${defaultMod}+N`, + section: 'app', + }, + { + key: 'shortcutSearch', + label: 'Search Notes', + storageKey: SETTINGS_KEYS.SHORTCUT_SEARCH, + defaultKey: `${defaultMod}+P`, + section: 'app', + }, + { + key: 'shortcutGraph', + label: 'Graph View', + storageKey: SETTINGS_KEYS.SHORTCUT_GRAPH, + defaultKey: `${defaultMod}+G`, + section: 'app', + }, + { + key: 'shortcutActionMenu', + label: 'Main Action Menu', + storageKey: SETTINGS_KEYS.SHORTCUT_ACTION_MENU, + defaultKey: `${defaultMod}+K`, + section: 'app', + }, + { + key: 'shortcutExport', + label: 'Export Note', + storageKey: SETTINGS_KEYS.SHORTCUT_EXPORT, + defaultKey: `${defaultMod}+E`, + section: 'app', + }, + { + key: 'shortcutSettings', + label: 'Open Settings', + storageKey: SETTINGS_KEYS.SHORTCUT_SETTINGS, + defaultKey: `${defaultMod}+Shift+S`, + section: 'app', + }, + { + key: 'shortcutRef', + label: 'Shortcuts Reference', + storageKey: SETTINGS_KEYS.SHORTCUT_REF, + defaultKey: `${defaultMod}+/`, + section: 'app', + }, + ] + + const [values, setValues] = useState<Record<string, string>>(() => { + const initial: Record<string, string> = {} + for (const sc of shortcuts) { + initial[sc.key] = getShortcut(sc.storageKey, sc.defaultKey) + } + return initial + }) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -61,33 +126,16 @@ export function KeybindsModal({ onClose }: KeybindsModalProps) { }, [onClose]) const handleSave = () => { - // Save Global Shortcuts - const oldShortcutNewNote = - localStorage.getItem('papercache-shortcut-newnote') || `${defaultMod}+Shift+N` - if (window.electronAPI.updateGlobalShortcut) { - window.electronAPI.updateGlobalShortcut('new-note', oldShortcutNewNote, shortcutNewNote) - } - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE, shortcutNewNote) - localStorage.setItem('papercache-shortcut-newnote', shortcutNewNote) - - const oldShortcutToggle = - localStorage.getItem('papercache-shortcut-toggle') || `${defaultMod}+Shift+C` - if (window.electronAPI.updateGlobalShortcut) { - window.electronAPI.updateGlobalShortcut('toggle', oldShortcutToggle, shortcutToggle) + for (const sc of shortcuts) { + if (sc.section === 'global' && sc.action && sc.oldShortcutStorageKey) { + const oldShortcut = localStorage.getItem(sc.oldShortcutStorageKey) || sc.defaultKey + if (window.electronAPI.updateGlobalShortcut) { + window.electronAPI.updateGlobalShortcut(sc.action, oldShortcut, values[sc.key]) + } + localStorage.setItem(sc.oldShortcutStorageKey, values[sc.key]) + } + localStorage.setItem(sc.storageKey, values[sc.key]) } - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TOGGLE, shortcutToggle) - localStorage.setItem('papercache-shortcut-toggle', shortcutToggle) - - // Save In-App Shortcuts - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TASKS, shortcutTasks) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_TIMERS, shortcutTimers) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_SEARCH, shortcutSearch) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_GRAPH, shortcutGraph) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_ACTION_MENU, shortcutActionMenu) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_EXPORT, shortcutExport) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_REF, shortcutRef) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_SETTINGS, shortcutSettings) - localStorage.setItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE_INAPP, shortcutNewNoteInApp) useAppStore .getState() @@ -96,19 +144,16 @@ export function KeybindsModal({ onClose }: KeybindsModalProps) { } const handleResetDefaults = () => { - setShortcutToggle(`${defaultMod}+Shift+C`) - setShortcutNewNote(`${defaultMod}+Shift+N`) - setShortcutTasks(`${defaultMod}+R`) - setShortcutTimers(`${defaultMod}+T`) - setShortcutSearch(`${defaultMod}+P`) - setShortcutGraph(`${defaultMod}+G`) - setShortcutActionMenu(`${defaultMod}+K`) - setShortcutExport(`${defaultMod}+E`) - setShortcutRef(`${defaultMod}+/`) - setShortcutSettings(`${defaultMod}+Shift+S`) - setShortcutNewNoteInApp(`${defaultMod}+N`) + const reset: Record<string, string> = {} + for (const sc of shortcuts) { + reset[sc.key] = sc.defaultKey + } + setValues(reset) } + const globalShortcuts = shortcuts.filter((s) => s.section === 'global') + const appShortcuts = shortcuts.filter((s) => s.section === 'app') + return ( <div className="settings-container" @@ -126,49 +171,26 @@ export function KeybindsModal({ onClose }: KeybindsModalProps) { <section> <h3>Global Shortcuts (OS Level)</h3> - <KeybindRow - label="Toggle App Visibility" - value={shortcutToggle} - onChange={setShortcutToggle} - /> - <KeybindRow - label="New Note (Global)" - value={shortcutNewNote} - onChange={setShortcutNewNote} - /> + {globalShortcuts.map((sc) => ( + <KeybindRow + key={sc.key} + label={sc.label} + value={values[sc.key]} + onChange={(val) => setValues((prev) => ({ ...prev, [sc.key]: val }))} + /> + ))} </section> <section> <h3>In-App Navigation & Actions</h3> - <KeybindRow - label="Open Reminders / Tasks" - value={shortcutTasks} - onChange={setShortcutTasks} - /> - <KeybindRow - label="Open Timers Panel" - value={shortcutTimers} - onChange={setShortcutTimers} - /> - <KeybindRow - label="New Note (In-App)" - value={shortcutNewNoteInApp} - onChange={setShortcutNewNoteInApp} - /> - <KeybindRow label="Search Notes" value={shortcutSearch} onChange={setShortcutSearch} /> - <KeybindRow label="Graph View" value={shortcutGraph} onChange={setShortcutGraph} /> - <KeybindRow - label="Main Action Menu" - value={shortcutActionMenu} - onChange={setShortcutActionMenu} - /> - <KeybindRow label="Export Note" value={shortcutExport} onChange={setShortcutExport} /> - <KeybindRow - label="Open Settings" - value={shortcutSettings} - onChange={setShortcutSettings} - /> - <KeybindRow label="Shortcuts Reference" value={shortcutRef} onChange={setShortcutRef} /> + {appShortcuts.map((sc) => ( + <KeybindRow + key={sc.key} + label={sc.label} + value={values[sc.key]} + onChange={(val) => setValues((prev) => ({ ...prev, [sc.key]: val }))} + /> + ))} </section> </div> diff --git a/src/components/TimersPage.tsx b/src/components/TimersPage.tsx index 2504d5c..ebc431c 100644 --- a/src/components/TimersPage.tsx +++ b/src/components/TimersPage.tsx @@ -1,14 +1,4 @@ -/** - * TimersPage – Active countdown timer management panel. - * - * Countdown accuracy: Uses a chained setTimeout pattern (instead of setInterval) - * to comply with the "no setInterval" rule. Each tick schedules the next tick - * dynamically, so drift correction is automatic. - * - * Background operation: The actual completion event is triggered by the Rust backend - * (tokio::time::sleep), so the timer fires even if the app is minimized. - * The frontend display is best-effort and syncs to the backend-derived endsAt timestamp. - */ +const TICK_INTERVAL_MS = 250 import { useState, useEffect, useRef } from 'react' import { useTimerStore, type Timer } from '../store/useTimerStore' @@ -47,11 +37,11 @@ function TimerItem({ timer, onRemove }: TimerItemProps) { const remaining = Math.max(0, timer.endsAt - Date.now()) if (remaining > 0) { // Schedule next tick in ~250ms for smooth display - timeoutRef.current = setTimeout(tick, 250) + timeoutRef.current = setTimeout(tick, TICK_INTERVAL_MS) } } - timeoutRef.current = setTimeout(tick, 250) + timeoutRef.current = setTimeout(tick, TICK_INTERVAL_MS) return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts index 8040907..31570d4 100644 --- a/src/hooks/useReminders.ts +++ b/src/hooks/useReminders.ts @@ -1,11 +1,10 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' import { listen } from '@tauri-apps/api/event' import { useAppStore, type Note } from '../store/useAppStore' import { SETTINGS_KEYS } from '../lib/settingsKeys' import { parseAllTasks } from '../lib/taskUtils' import type { ReminderPayload } from '../types' -// Monotonically increasing token to prevent stale scheduleReminders calls let scheduleToken = 0 function parseReminders(content: string, noteId: string): ReminderPayload[] { @@ -39,25 +38,19 @@ function collectFutureReminders(notes: Note[]): ReminderPayload[] { export function useReminders() { const notes = useAppStore((state) => state.notes) - const prevNotesRef = useRef<Note[]>([]) - // Schedule reminders with a monotonic token to prevent stale overwrites useEffect(() => { - prevNotesRef.current = notes - const pending = collectFutureReminders(notes) const token = ++scheduleToken + const pending = collectFutureReminders(notes) + if (token !== scheduleToken) return window.electronAPI .scheduleReminders(pending) - .then(() => { - // Only advance prevNotesRef if we're still the latest call - if (token !== scheduleToken) return - }) + .then(() => {}) // eslint-disable-next-line no-console .catch((e) => console.error('Failed to schedule reminders', e)) }, [notes]) - // Listen for the native "reminder-fired" event from the backend useEffect(() => { let unlisten: (() => void) | undefined let disposed = false diff --git a/src/lib/editor/MathEvaluator.ts b/src/lib/editor/MathEvaluator.ts index 6279bf5..82e33c0 100644 --- a/src/lib/editor/MathEvaluator.ts +++ b/src/lib/editor/MathEvaluator.ts @@ -2,6 +2,8 @@ import type { EditorView } from '@codemirror/view' import { getScope } from './VariableScope' import { Parser, type Values } from 'expr-eval' +const MATH_EVAL_DEBOUNCE_MS = 300 + export function evaluateMath( docStr: string, scope: Record<string, unknown> @@ -97,6 +99,6 @@ export class MathEvaluator { if (changes.length > 0) { view.dispatch({ changes }) } - }, 300) + }, MATH_EVAL_DEBOUNCE_MS) } } diff --git a/src/lib/editor/VariableScope.ts b/src/lib/editor/VariableScope.ts index 8f2468e..3bd93e7 100644 --- a/src/lib/editor/VariableScope.ts +++ b/src/lib/editor/VariableScope.ts @@ -4,6 +4,8 @@ import { useVariableStore } from '../../store/useVariableStore' import { Parser, type Values } from 'expr-eval' import { MathEvaluator } from './MathEvaluator' +const SCOPE_DEBOUNCE_MS = 300 + export const scopeChangedEffect = StateEffect.define<void>() export class VariableScope { @@ -54,7 +56,7 @@ export class VariableScope { MathEvaluator.triggerMathEvaluation(view) } } - }, 300) + }, SCOPE_DEBOUNCE_MS) } } diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index a821a48..8289242 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -171,18 +171,18 @@ export function useEditorExtensions() { messages.push({ role: 'user', content: finalPrompt }) - const completion = (await window.electronAPI.openAIChat({ + const completion = await window.electronAPI.openAIChat({ model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free', messages: messages, baseUrl: finalBaseUrl || '', - })) as { - choices?: Array<{ message?: { content?: string } }> - error?: { message?: string } - } + }) let response: string - if (completion.choices && completion.choices.length > 0) { - response = completion.choices[0].message?.content || '' + const choice = completion.choices?.[0] + if (choice?.message?.content) { + response = choice.message.content + } else if (choice?.message?.content === '') { + response = '' } else if (completion.error) { throw new Error(completion.error.message || 'Unknown API Error') } else { diff --git a/src/setupTests.ts b/src/setupTests.ts index 2a351fb..8c3300c 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -54,7 +54,7 @@ if (typeof window !== 'undefined') { saveNote: vi.fn().mockResolvedValue(true), deleteNote: vi.fn().mockResolvedValue(true), renameNote: vi.fn().mockResolvedValue(true), - openAIChat: vi.fn().mockResolvedValue(''), + openAIChat: vi.fn().mockResolvedValue({ choices: [{ message: { content: '' } }] }), setApiKey: vi.fn().mockResolvedValue(true), getApiKeyStatus: vi.fn().mockResolvedValue(true), checkForUpdates: vi.fn(), @@ -65,7 +65,6 @@ if (typeof window !== 'undefined') { quitApp: vi.fn(), openExternal: vi.fn(), openFile: vi.fn(), - onSwipeGesture: vi.fn().mockReturnValue(() => {}), setLaunchAtStartup: vi.fn(), updateGlobalShortcut: vi.fn(), onTriggerNewNote: vi.fn().mockReturnValue(() => {}), @@ -74,8 +73,8 @@ if (typeof window !== 'undefined') { safeStorageDecrypt: vi.fn((val) => Promise.resolve(val)), onPowerSuspend: vi.fn().mockReturnValue(() => {}), onPowerResume: vi.fn().mockReturnValue(() => {}), - pauseShortcuts: vi.fn(), - resumeShortcuts: vi.fn(), + pauseShortcuts: vi.fn().mockResolvedValue(undefined), + resumeShortcuts: vi.fn().mockResolvedValue(undefined), onUpdateReady: vi.fn().mockReturnValue(() => {}), scheduleReminders: vi.fn().mockResolvedValue(undefined), cancelReminders: vi.fn().mockResolvedValue(undefined), diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts index 8b858b0..7b1dec9 100644 --- a/src/store/useAppStore.test.ts +++ b/src/store/useAppStore.test.ts @@ -7,7 +7,6 @@ describe('useAppStore', () => { useAppStore.setState({ notes: [], currentNoteIndex: 0, - themePreset: 'grid-light', showGraphView: false, showRemindersView: false, isRenaming: false, @@ -25,7 +24,6 @@ describe('useAppStore', () => { const state = useAppStore.getState() expect(state.notes).toEqual([]) expect(state.currentNoteIndex).toBe(0) - expect(state.themePreset).toBe('grid-light') expect(state.showNoteSearch).toBe(false) }) diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index e14cf07..52660d0 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -15,7 +15,6 @@ export interface Toast { interface AppState { notes: Note[] currentNoteIndex: number - themePreset: string isHyprland: boolean // UI state @@ -37,7 +36,6 @@ interface AppState { setNotes: (notes: Note[] | ((prev: Note[]) => Note[])) => void setCurrentNoteIndex: (index: number) => void - setThemePreset: (preset: string) => void setIsHyprland: (isHyprland: boolean) => void setShowGraphView: (show: boolean | ((prev: boolean) => boolean)) => void @@ -58,87 +56,75 @@ interface AppState { setIsRecordingShortcut: (isRecording: boolean) => void } -export const useAppStore = create<AppState>((set) => ({ - notes: [], - currentNoteIndex: 0, - themePreset: (localStorage.getItem('papercache-theme') as string) || 'grid-light', - isHyprland: false, +export const useAppStore = create<AppState>((set) => { + const booleanSetter = (key: keyof AppState) => (value: boolean | ((prev: boolean) => boolean)) => + set((state) => ({ + [key]: typeof value === 'function' ? value(state[key] as boolean) : value, + })) - showGraphView: false, - showRemindersView: false, - showTimersView: false, - toasts: [], - isRenaming: false, - renameValue: '', - showNoteSearch: false, - noteSearchQuery: '', - searchSelectedIndex: 0, - showNoteActionMenu: false, - showMainActionMenu: false, - actionMenuIndex: 0, - showSettingsModal: false, - showKeybindsModal: false, - isRecordingShortcut: false, + const simpleSetter = + <K extends keyof AppState>(key: K) => + (value: AppState[K]) => + set({ [key]: value } as unknown as Partial<AppState>) - setNotes: (notes) => - set((state) => ({ - notes: typeof notes === 'function' ? notes(state.notes) : notes, - })), - setCurrentNoteIndex: (currentNoteIndex) => set({ currentNoteIndex }), - setThemePreset: (themePreset) => set({ themePreset }), - setIsHyprland: (isHyprland) => set({ isHyprland }), + return { + notes: [], + currentNoteIndex: 0, + isHyprland: false, - setShowGraphView: (showGraphView) => - set((state) => ({ - showGraphView: - typeof showGraphView === 'function' ? showGraphView(state.showGraphView) : showGraphView, - })), - setShowRemindersView: (showRemindersView) => - set((state) => ({ - showRemindersView: - typeof showRemindersView === 'function' - ? showRemindersView(state.showRemindersView) - : showRemindersView, - })), - setShowTimersView: (showTimersView) => - set((state) => ({ - showTimersView: - typeof showTimersView === 'function' - ? showTimersView(state.showTimersView) - : showTimersView, - })), - addToast: (toast) => - set((state) => ({ - toasts: [...state.toasts, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }], - })), - removeToast: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })), - setIsRenaming: (isRenaming) => set({ isRenaming }), - setRenameValue: (renameValue) => set({ renameValue }), - setShowNoteSearch: (showNoteSearch) => set({ showNoteSearch }), - setNoteSearchQuery: (noteSearchQuery) => set({ noteSearchQuery }), - setSearchSelectedIndex: (searchSelectedIndex) => - set((state) => ({ - searchSelectedIndex: - typeof searchSelectedIndex === 'function' - ? searchSelectedIndex(state.searchSelectedIndex) - : searchSelectedIndex, - })), - setShowNoteActionMenu: (showNoteActionMenu) => set({ showNoteActionMenu }), - setShowMainActionMenu: (showMainActionMenu) => - set((state) => ({ - showMainActionMenu: - typeof showMainActionMenu === 'function' - ? showMainActionMenu(state.showMainActionMenu) - : showMainActionMenu, - })), - setActionMenuIndex: (actionMenuIndex) => - set((state) => ({ - actionMenuIndex: - typeof actionMenuIndex === 'function' - ? actionMenuIndex(state.actionMenuIndex) - : actionMenuIndex, - })), - setShowSettingsModal: (showSettingsModal) => set({ showSettingsModal }), - setShowKeybindsModal: (showKeybindsModal) => set({ showKeybindsModal }), - setIsRecordingShortcut: (isRecordingShortcut) => set({ isRecordingShortcut }), -})) + showGraphView: false, + showRemindersView: false, + showTimersView: false, + toasts: [], + isRenaming: false, + renameValue: '', + showNoteSearch: false, + noteSearchQuery: '', + searchSelectedIndex: 0, + showNoteActionMenu: false, + showMainActionMenu: false, + actionMenuIndex: 0, + showSettingsModal: false, + showKeybindsModal: false, + isRecordingShortcut: false, + + setNotes: (notes) => + set((state) => ({ + notes: typeof notes === 'function' ? notes(state.notes) : notes, + })), + setCurrentNoteIndex: simpleSetter('currentNoteIndex'), + setIsHyprland: simpleSetter('isHyprland'), + + setShowGraphView: booleanSetter('showGraphView'), + setShowRemindersView: booleanSetter('showRemindersView'), + setShowTimersView: booleanSetter('showTimersView'), + addToast: (toast) => + set((state) => ({ + toasts: [...state.toasts, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }], + })), + removeToast: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })), + setIsRenaming: simpleSetter('isRenaming'), + setRenameValue: simpleSetter('renameValue'), + setShowNoteSearch: simpleSetter('showNoteSearch'), + setNoteSearchQuery: simpleSetter('noteSearchQuery'), + setSearchSelectedIndex: (searchSelectedIndex) => + set((state) => ({ + searchSelectedIndex: + typeof searchSelectedIndex === 'function' + ? searchSelectedIndex(state.searchSelectedIndex) + : searchSelectedIndex, + })), + setShowNoteActionMenu: simpleSetter('showNoteActionMenu'), + setShowMainActionMenu: booleanSetter('showMainActionMenu'), + setActionMenuIndex: (actionMenuIndex) => + set((state) => ({ + actionMenuIndex: + typeof actionMenuIndex === 'function' + ? actionMenuIndex(state.actionMenuIndex) + : actionMenuIndex, + })), + setShowSettingsModal: simpleSetter('showSettingsModal'), + setShowKeybindsModal: simpleSetter('showKeybindsModal'), + setIsRecordingShortcut: simpleSetter('isRecordingShortcut'), + } +}) diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 8c36846..b4e1dd2 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -14,55 +14,10 @@ export interface SettingsState { aiColor: string mathColor: string - setThemePreset: (preset: string) => void - setFontFamily: (font: string) => void - setShowRulings: (show: boolean) => void - setBgType: (type: 'color' | 'image') => void - setBgColor: (color: string) => void - setBgImage: (image: string) => void - setTextColor: (color: string) => void - setNumColor: (color: string) => void - setSymColor: (color: string) => void - setAiColor: (color: string) => void - setMathColor: (color: string) => void setSettings: ( settings: - | Partial< - Omit< - SettingsState, - | 'setSettings' - | 'setThemePreset' - | 'setFontFamily' - | 'setShowRulings' - | 'setBgType' - | 'setBgColor' - | 'setBgImage' - | 'setTextColor' - | 'setNumColor' - | 'setSymColor' - | 'setAiColor' - | 'setMathColor' - > - > - | (( - state: SettingsState - ) => Partial< - Omit< - SettingsState, - | 'setSettings' - | 'setThemePreset' - | 'setFontFamily' - | 'setShowRulings' - | 'setBgType' - | 'setBgColor' - | 'setBgImage' - | 'setTextColor' - | 'setNumColor' - | 'setSymColor' - | 'setAiColor' - | 'setMathColor' - > - >) + | Partial<Omit<SettingsState, 'setSettings'>> + | ((state: SettingsState) => Partial<Omit<SettingsState, 'setSettings'>>) ) => void } @@ -81,17 +36,6 @@ export const useSettingsStore = create<SettingsState>()( aiColor: '#8b5cf6', mathColor: '#10b981', - setThemePreset: (themePreset) => set({ themePreset }), - setFontFamily: (fontFamily) => set({ fontFamily }), - setShowRulings: (showRulings) => set({ showRulings }), - setBgType: (bgType) => set({ bgType }), - setBgColor: (bgColor) => set({ bgColor }), - setBgImage: (bgImage) => set({ bgImage }), - setTextColor: (textColor) => set({ textColor }), - setNumColor: (numColor) => set({ numColor }), - setSymColor: (symColor) => set({ symColor }), - setAiColor: (aiColor) => set({ aiColor }), - setMathColor: (mathColor) => set({ mathColor }), setSettings: (settings) => set((state) => ({ ...state, diff --git a/src/store/useTimerStore.ts b/src/store/useTimerStore.ts index 257f56e..f7084d1 100644 --- a/src/store/useTimerStore.ts +++ b/src/store/useTimerStore.ts @@ -8,6 +8,8 @@ import { create } from 'zustand' +const COMPLETED_TIMER_CLEANUP_MS = 10000 + export type TimerStatus = 'running' | 'paused' | 'completed' export interface Timer { @@ -29,7 +31,6 @@ interface TimerState { tickTimer: (id: string) => void completeTimer: (id: string) => void pauseTimer: (id: string) => void - resumeTimer: (id: string) => void cleanExpiredTimers: () => void } @@ -39,7 +40,9 @@ export const useTimerStore = create<TimerState>((set) => ({ cleanExpiredTimers: () => { const now = Date.now() set((state) => ({ - timers: state.timers.filter((t) => t.status !== 'completed' || now - t.endsAt < 10000), + timers: state.timers.filter( + (t) => t.status !== 'completed' || now - t.endsAt < COMPLETED_TIMER_CLEANUP_MS + ), })) }, @@ -93,8 +96,4 @@ export const useTimerStore = create<TimerState>((set) => ({ ), })) }, - - resumeTimer: () => { - // Backend does not support pause/resume yet; resume action is gated in the UI - }, })) diff --git a/src/types.d.ts b/src/types.d.ts index b993711..fb1a5d2 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -14,7 +14,10 @@ export interface ElectronAPI { model: string messages: { role: string; content: string }[] baseUrl: string - }) => Promise<unknown> + }) => Promise<{ + choices?: Array<{ message?: { content?: string } }> + error?: { message?: string } + }> setApiKey: (key: string) => Promise<boolean> getApiKeyStatus: () => Promise<boolean> checkForUpdates: () => Promise<void> @@ -32,7 +35,6 @@ export interface ElectronAPI { quitApp: () => Promise<void> openExternal: (url: string) => Promise<void> openFile: (path: string) => Promise<void> - onSwipeGesture: (callback: (direction: string) => void) => () => void getLaunchAtStartup: () => Promise<boolean> setLaunchAtStartup: (value: boolean) => Promise<void> updateGlobalShortcut: (action: string, oldShortcut: string, newShortcut: string) => void @@ -42,8 +44,8 @@ export interface ElectronAPI { safeStorageDecrypt: (val: string) => Promise<string> onPowerSuspend: (callback: () => void) => () => void onPowerResume: (callback: () => void) => () => void - pauseShortcuts: () => void - resumeShortcuts: () => void + pauseShortcuts: () => Promise<void> + resumeShortcuts: () => Promise<void> onUpdateReady: (callback: () => void) => () => void }