From 0a6d7f18d2a5a7190e43d6b630a7994e62d87f16 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 15:45:41 +0530 Subject: [PATCH 1/9] feat: graph view overhaul, Windows focus-loss fix, Cmd+/ shortcuts, welcome revamp (v0.5.3) --- .gitignore | 2 + AUDIT_LOG.md | 46 +++ CHANGELOG.md | 30 ++ README.md | 6 +- features.md | 14 +- notes/New Features in v0.5.3.md | 16 + package-lock.json | 339 +++++++++--------- package.json | 10 +- src-tauri/Cargo.lock | 112 +++++- src-tauri/Cargo.toml | 3 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/fs.rs | 12 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/notifications.rs | 150 ++++++++ src-tauri/src/commands/shortcuts.rs | 26 +- src-tauri/src/lib.rs | 37 +- src-tauri/tauri.conf.json | 2 +- src/App.css | 298 ++++++++++++++++ src/App.tsx | 104 +++++- src/GraphView.tsx | 453 +++++++++++++++++++----- src/api.ts | 5 + src/components/MainActionMenu.tsx | 13 + src/components/TimersPage.tsx | 299 ++++++++++++++++ src/hooks/useGlobalHotkey.ts | 42 +++ src/hooks/useReminders.test.ts | 168 +++++---- src/hooks/useReminders.ts | 117 +++--- src/lib/editor/dslPlugin.ts | 127 +++++++ src/lib/editor/extensions.ts | 10 + src/lib/editor/slashCommands.ts | 1 + src/setupTests.ts | 4 + src/store/useAppStore.ts | 25 ++ src/store/useTimerStore.ts | 90 +++++ src/types.d.ts | 4 + 33 files changed, 2118 insertions(+), 451 deletions(-) create mode 100644 notes/New Features in v0.5.3.md create mode 100644 src-tauri/src/commands/notifications.rs create mode 100644 src/components/TimersPage.tsx create mode 100644 src/lib/editor/dslPlugin.ts create mode 100644 src/store/useTimerStore.ts diff --git a/.gitignore b/.gitignore index 13f6b3d..b620170 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ target/ src-tauri/target/ src-tauri/gen/ window-state.json +*.tar.gz +*.tar.xz diff --git a/AUDIT_LOG.md b/AUDIT_LOG.md index d4d6c04..6febb2a 100644 --- a/AUDIT_LOG.md +++ b/AUDIT_LOG.md @@ -2,6 +2,52 @@ This log tracks all significant changes, updates, and versions in the PaperCache project. +## 2026-06-24 - (Uncommitted) +**Change:** feat: graph view rebuilt, Windows focus-loss fix, Cmd+/ shortcuts, welcome revamp (v0.5.3) + +**Details/Why:** +Major graph view overhaul and cross-platform fixes: + +1. **Flat Circle Nodes with Occlusion**: Replaced default 3D spheres with `CircleGeometry` meshes. Circles render in the transparent pass at `renderOrder: 1` (after edges), with `depthWrite: true` — edges passing through nodes are now cleanly occluded. + +2. **Always-Visible Labels**: Each node returns a `THREE.Group` containing the circle mesh plus a canvas-based `THREE.Sprite` label. Labels are positioned below each circle and always visible (never fade on zoom). + +3. **Cmd+F Fuzzy Search in Graph**: Search bar with character-order fuzzy matching, arrow-key navigation, and Enter to fly the camera to the matched node. + +4. **Folder Clustering**: `d3-force` `forceX`/`forceY` at 0.008 strength weakly attract same-folder nodes toward shared centroids on a 60-unit radius, creating subtle visual groupings. + +5. **Cmd+Shift+N No Longer Hides Open App**: Changed the Rust global shortcut handler in `shortcuts.rs` — for `new-note` action, window only shows if hidden; never hides when already visible. All other shortcuts (toggle, etc.) retain `toggle_window` behavior. + +6. **Windows Focus-Loss Debounce**: `lib.rs` focus-loss handler now uses a 200ms debounce via `AtomicBool` + `std::thread::spawn`. Clicking the title bar on Windows 10 briefly triggers `Focused(false)` — the debounce waits 200ms for a matching `Focused(true)` before hiding. On macOS, hide remains immediate. + +7. **Cmd+/ Shortcuts Reference**: New shortcut opens or auto-creates `Shortcuts.md` with all keyboard shortcuts and slash commands listed. + +8. **Fresh Install Welcome**: Version check in `App.tsx` detects `null` last-seen-version (fresh install) and opens `Welcome.md` instead of looking for a new-features note. The Rust onboarding template (`fs.rs`) was revamped with a bullet-list feature overview. + +9. **Lazy-Loaded Graph**: `GraphView` dynamically imported via `React.lazy()` — Three.js bundle (~1.3 MB) loads only on first graph open. + +10. **Doc Updates**: Version bumped to 0.5.3 across all manifest files. `CHANGELOG.md`, `features.md`, `README.md`, `notes/New Features in v0.5.3.md` all updated. + +--- + +## 2026-06-24 - (Uncommitted) +**Change:** feat: native notifications, timers, DSL regex engine, WebGL graph with folder attraction + +**Details/Why:** +Implemented four major platform features as requested: + +1. **Native Reminder Notifications (Rust Backend):** Removed the old Web Notification API + `setTimeout` polling loop from the JS renderer. All reminder scheduling is now delegated to a new `commands/notifications.rs` Rust module. Uses `tokio::spawn` + `tokio::time::sleep` to wait for exact due times and fires native OS notifications via `tauri_plugin_notification`. This ensures reminders are reliable when the app is minimized, handles OS notification permission gracefully, and eliminates renderer-side timer drift. A `reminder-fired` Tauri event is emitted to the frontend to show an in-app toast and update localStorage. + +2. **Timers:** Added a new `useTimerStore.ts` Zustand store and `TimersPage.tsx` component with a glassmorphic panel UI. Countdowns use a chained `setTimeout` pattern (no `setInterval`) for drift-corrected display. The Rust backend (`schedule_timer` / `cancel_timer` commands) fires the native OS notification and emits `timer-complete` on completion — ensuring timers complete even when minimized. A "Timers" button was added to `MainActionMenu`, and the `/timer` slash command was added to the editor. + +3. **DSL Regex Parsing Engine:** Created `src/lib/editor/dslPlugin.ts` — a factory `createRegexPlugin(rules: DSLRule[])` that generates CodeMirror ViewPlugins. Scans only `view.visibleRanges` per update tick (O(visible lines)), guaranteeing lag-free typing at any document size. Supports `className` mark decorations, `widget` factories, and `onMatch` callbacks per rule. + +4. **WebGL Graph with Folder Attraction:** Replaced `react-force-graph-2d` (Canvas 2D) with `react-force-graph-3d` (Three.js / WebGL) lazy-loaded via `React.Suspense`. The graph is configured in 2D mode (z-axis locked). Added custom `d3-force` `forceX` and `forceY` forces that pull nodes toward per-folder centroid positions, creating organic cluster layouts where notes from the same folder attract each other. + +**Files changed:** `src-tauri/src/commands/notifications.rs` [NEW], `src-tauri/src/commands/mod.rs`, `src-tauri/src/lib.rs`, `src-tauri/Cargo.toml`, `src-tauri/capabilities/default.json`, `package.json`, `src/types.d.ts`, `src/api.ts`, `src/hooks/useReminders.ts`, `src/hooks/useReminders.test.ts`, `src/store/useAppStore.ts`, `src/store/useTimerStore.ts` [NEW], `src/components/TimersPage.tsx` [NEW], `src/components/MainActionMenu.tsx`, `src/lib/editor/slashCommands.ts`, `src/lib/editor/dslPlugin.ts` [NEW], `src/GraphView.tsx`, `src/App.tsx`, `src/App.css`. + +--- + ## 2026-06-24 - (Uncommitted) **Change:** feat: add slash command autosuggest, auto-open new features note, and tag action menu for v0.5.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e75fa1a..4281ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,42 @@ All notable, user-facing changes to PaperCache will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.5.3] - 2026-06-24 + +### Added +- **Redesigned Graph View**: Nodes are now flat `CircleGeometry` with `depthWrite: true` and `renderOrder: 1` in the transparent pass, cleanly occluding edges behind them. Always-visible canvas-based text labels positioned below each node. +- **Cmd+F Fuzzy Search in Graph**: Press `Cmd+F` in graph view to search note names by fuzzy character matching. Navigate with arrow keys, confirm with Enter to fly the camera to the matching node. +- **Folder Clustering**: Notes sharing a folder are weakly attracted to a shared centroid via forceX/forceY at 0.008 strength, producing subtle visual grouping without breaking the unified cluster. +- **Cmd+/ Shortcuts Reference**: Press `Cmd+/` (or `Cmd+?`) to open or create a `Shortcuts.md` note listing all keyboard shortcuts and slash commands. +- **Fresh Install Welcome**: First-time launch now opens `Welcome.md` instead of looking for a new-features note. The welcome note has been revamped with a full feature overview. +- **Lazy-Loaded Graph**: `GraphView` is dynamically imported with `React.lazy()` so the Three.js bundle (~1.3 MB) loads only when the graph is opened. +- **Smooth Graph Fade-in**: The graph overlay animates in with a 250ms CSS keyframe fade. +- **Persistent Node Positions**: After closing the graph view, node positions are cached and restored on next open, preserving manual arrangement. + +### Changed +- **Cmd+Shift+N Behavior**: The global new-note shortcut no longer hides the app if it's already visible. It only shows the window when hidden. The shortcut always creates a new note regardless. +- **Node Circle Size**: Increased from radius 8 to 12, with labels shifted and scaled accordingly. +- **Windows Focus-Loss Debounce**: Hiding on focus loss now uses a 200ms debounce to prevent accidental hide when clicking the title bar for drag or resize. +- **Node Positions Cache**: Force simulation no longer pushes dragged nodes back — the strength accessor skips nodes in the dragged set. +- **Graph Controls**: OrbitControls configured with `enableRotate = false`, `LEFT = PAN`, `MIDDLE = DOLLY` for a pure 2D navigation experience. + +### Fixed +- Fixed edges/lines showing through circle nodes by making circle meshes render in the transparent pass after links. +- Fixed graph view opening inside the shortcut handler (removed stale `showGraphView` setter call from version check effect). +- Fixed blank graph issue on re-open by caching node positions at component unmount. + +--- + ## [v0.5.2] - 2026-06-24 ### Added - **Slash Command Autosuggest**: Added an inline ghost text autosuggest widget for slash commands (e.g., `/check`, `/ai`). Pressing `Tab` instantly completes the command without interrupting typing flow. - **Auto-Open Version Notes**: Upon updating, PaperCache now automatically opens a summary note detailing the new features in the latest release and silently cleans up previous version notes from the workspace. - **Tag Context Menu**: Right-clicking a tag pill now reveals a beautifully styled inline action menu allowing users to easily delete all notes under that tag, or export them concatenated together into a single Markdown file directly via native system dialogs. +- **Native OS Reminder Notifications**: Task reminders (`/task`) now fire native OS notifications via the Rust backend using `tokio::time::sleep` + `tauri_plugin_notification`. Notifications fire reliably even when the app is minimized or out of focus, and gracefully handle OS-level permission denials. +- **Countdown Timers**: New timer panel (accessible via the action menu or `/timer` command) lets you create, view, pause/resume, and cancel countdown timers. Timers display a live countdown using drift-corrected `setTimeout` chains and trigger both a native OS notification and an in-app alert on completion — even if you're viewing a different note. +- **DSL Regex Parsing Engine**: New `createRegexPlugin()` factory in `dslPlugin.ts` enables flexible, regex-based Domain Specific Language parsing in the editor. Scans only visible ranges for O(visible lines) performance — lag-free at any document size. Supports custom mark decorations, widget injections, and match callbacks. +- **WebGL Graph View**: The Graph View has been rewritten using Three.js WebGL via `react-force-graph-3d`. Notes in the same folder are attracted to shared centroid positions via custom `d3-force` simulation rules, causing them to cluster together naturally. The graph is lazy-loaded to avoid impacting editor startup time. ### Fixed - Fixed an issue where the unified search view layout could overlap with the context menu or hide important tag management options. diff --git a/README.md b/README.md index 28782d6..da417cd 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Summon it with a hotkey. Jot. Dismiss. It stays out of your way until you need i - **Interactive Checkboxes & Slash Commands** — Type `/` to see inline autosuggestions (like `/check`, `/task`, or `/ai`). Press `Tab` to instantly complete them. Checkboxes strike through text when clicked. - **Tasks & Reminders** — Type `/task` followed by `@ 1d2h` to set a due date. Press `Cmd+T` to open a unified Tasks view that tracks all your pending items and due times. - **Tags & folders** — `!tagname` for tags, `/` in note titles for folders. Right-click any tag pill to export or delete all tagged notes at once. -- **Graph view** — see how your notes connect (`Cmd+G`). +- **Graph view** — press `Cmd+G` to open a 2D knowledge graph with flat circle nodes, always-visible labels, and folder-based clustering. Press `Cmd+F` to fuzzy-search nodes and fly directly to the match. > **Deep Dive:** For a comprehensive list of every single feature in PaperCache, check out [features.md](features.md). > **Performance:** We take speed and battery life seriously. Check out our [Performance Audit](PERFORMANCE_AUDIT.md). @@ -81,14 +81,16 @@ Built with Tauri, Rust, React, TypeScript, and Vite. | `Cmd+Shift+N` | New note (global, configurable) | | `Cmd+Shift+S` | Open settings panel | | `Cmd+N` | New note (in-app) | +| `Cmd+/` | Open shortcuts reference | | `Cmd+T` | Open Tasks page | | `Cmd+K` | Main action menu | | `Cmd+P` | Search notes | | `Cmd+G` | Graph view | +| `Cmd+F` | Search in graph (while graph is open) | | `Cmd+H` | Highlight selected text | | `Cmd+E` | Export note | | `Cmd+Click` | Follow internal link | -| `Esc` | Close menus or modals | +| `Esc` | Close menus / modals | --- diff --git a/features.md b/features.md index 46d8fdc..1274c27 100644 --- a/features.md +++ b/features.md @@ -25,10 +25,16 @@ This document outlines every feature available in the PaperCache codebase, organ - **Note Folders**: Implicitly organize notes into folders by adding a `/` in the title (e.g., `projects/PaperCache.md`). Folders are auto-detected, color-coded, and cleanly removed from disk if they become empty. - **Internal Note Linking**: Link to other notes using markdown links like `[link text](/file Note.md)` or simply `/file Note.md`. Click the link with `Cmd+Click` or `Ctrl+Click` to instantly navigate to it. URL links also open in the system browser. -- **Interactive Graph View**: An interactive, visual 2D node graph (`Cmd+G`) showing all connected notes, color-coded automatically based on their implicit folder. Click a node to open it. +- **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. + - **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. - **Global Note Search**: Open an omnibar search (`Cmd+P`) to quickly fuzzy-search note contents and file names across the entire workspace. - **Tagging System & Context Menu**: Add inline tags using `!tagname`. The global search bar aggregates all unique tags as clickable filters to quickly isolate notes. Right-click any tag pill to open an inline action menu, allowing you to bulk-delete or export all notes containing that tag into a single Markdown file. - **Auto-Renaming**: Notes are auto-created with a timestamp ID. The title is intelligently inferred from the first line of the file (e.g., `# Header`), but can also be manually renamed by clicking the title bar. +- **Shortcuts Reference** (`Cmd+/` or `Cmd+?`): Press this anywhere in the app to open or create a `Shortcuts.md` note listing every keyboard shortcut and slash command. ## Artificial Intelligence @@ -40,11 +46,11 @@ This document outlines every feature available in the PaperCache codebase, organ ## Desktop System Integration -- **Stealth / Background Mode**: Click away or lose focus, and the app instantly hides itself. On macOS, it runs as an "accessory" and hides its dock icon completely, acting like a true floating utility. +- **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**: - - `Cmd+Shift+N` (configurable): Instantly spawn a new floating note from anywhere on your OS. + - `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. - **Fluid Settings**: Opening the Settings menu dynamically inherits the exact dimensions and on-screen coordinates of your current notepad for a native, seamless transition. @@ -52,3 +58,5 @@ This document outlines every feature available in the PaperCache codebase, organ - **Exporting Options**: Export individual notes straight to a local `.md` file on your filesystem via the main menu or search list. - **Safe Tutorials**: Auto-generates fully functional Markdown tutorials in a `commands/` folder upon first launch. Prevents accidental deletion of these core tutorial files. - **Smart Version Updates**: Upon updating to a new release, PaperCache automatically opens a "New Features" summary note on your first launch and cleanly deletes older version notes behind the scenes to keep your workspace tidy. +- **Fresh Install Welcome**: First-time users automatically open to a revamped `Welcome.md` note with a complete feature overview instead of an empty editor. +- **Node Positions Persist**: After closing the graph view, node positions are cached and restored on next open, preserving manual arrangement. diff --git a/notes/New Features in v0.5.3.md b/notes/New Features in v0.5.3.md new file mode 100644 index 0000000..e40d60a --- /dev/null +++ b/notes/New Features in v0.5.3.md @@ -0,0 +1,16 @@ +# New Features in v0.5.3 + +Welcome to PaperCache v0.5.3! + +Here are the new features implemented in this version: +- **Redesigned Graph View**: Nodes are now rendered as clean, flat circles that properly occlude edges passing through them. Labels are always visible below each node. Circle size increased for better readability. +- **Cmd+F Fuzzy Search in Graph**: Press `Cmd+F` in graph view to fuzzy-search note names. Navigate results with arrow keys, press Enter to pan/zoom to the matched node. +- **Folder Clustering**: Notes in the same folder are gently attracted toward a shared centroid, creating subtle visual groupings. The graph still forms a single connected cluster. +- **Better Cmd+Shift+N Behavior**: When the app is already open, the global new-note shortcut now creates a new note without hiding the window. It only toggles visibility when the app is hidden. +- **Windows Focus-Loss Fix**: Clicking the title bar to drag or resize on Windows no longer accidentally hides the app. +- **Fresh Install Welcome**: First-time users now automatically open to the `Welcome.md` note with a feature overview. +- **Shortcuts Reference**: Press `Cmd+/` to open a `Shortcuts.md` note listing all keyboard shortcuts and slash commands. +- **Fade-in Animation**: The graph view now fades in smoothly when opened. +- **Lazy-Loaded Graph**: The Three.js graph engine is lazy-loaded, keeping the main bundle lean and startup fast. + +*(If you have read this note, feel free to delete it)* diff --git a/package-lock.json b/package-lock.json index 5aaa462..b79a140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,27 @@ { "name": "papercache", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papercache", - "version": "0.5.2", + "version": "0.5.3", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-global-shortcut": "^2.3.2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", - "expr-eval": "^2.0.2" + "@types/d3-force": "^3.0.10", + "d3-force": "^3.0.0", + "expr-eval": "^2.0.2", + "react-force-graph-3d": "^1.29.1", + "three": "^0.184.0" }, "devDependencies": { "@codemirror/commands": "^6.10.3", @@ -50,7 +55,6 @@ "prettier": "^3.8.3", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-force-graph-2d": "^1.29.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", @@ -313,7 +317,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -394,6 +397,7 @@ "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -483,6 +487,7 @@ "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -521,6 +526,7 @@ "version": "6.7.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", + "dev": true, "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -543,6 +549,7 @@ "version": "6.43.2", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.2.tgz", "integrity": "sha512-8kU6WNRYBKV9Sw3cxNz+uSvUvx3tt+1qgupGFPubnbLFDHOgh5qQdIGmXcD7bkA/PROK6LDKVhKMpcY7H++Amg==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -694,31 +701,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -1438,6 +1420,7 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "dev": true, "license": "MIT" }, "node_modules/@lezer/css": { @@ -1456,6 +1439,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.3.0" @@ -1489,6 +1473,7 @@ "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -1509,6 +1494,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -2087,6 +2073,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", @@ -2194,7 +2189,6 @@ "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -2226,6 +2220,12 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2757,11 +2757,26 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/3d-force-graph": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz", + "integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-forcegraph": "1", + "three-render-objects": "^1.41" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/accessor-fn": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2909,17 +2924,6 @@ "node": ">=6.0.0" } }, - "node_modules/bezier-js": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", - "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2999,19 +3003,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-color-tracker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", - "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3082,6 +3073,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -3131,7 +3123,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -3144,14 +3135,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", - "dev": true, "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3161,41 +3150,29 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" } }, - "node_modules/d3-drag": { + "node_modules/d3-force": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dev": true, + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", - "d3-selection": "3" + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { "node": ">=12" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-force-3d": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", - "dev": true, "license": "MIT", "dependencies": { "d3-binarytree": "1", @@ -3212,7 +3189,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3222,7 +3198,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -3235,14 +3210,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", - "dev": true, "license": "MIT" }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3252,7 +3225,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -3269,7 +3241,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -3283,9 +3254,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3294,7 +3263,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -3307,7 +3275,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -3320,44 +3287,18 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dev": true, "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, "engines": { "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" } }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dev": true, - "license": "ISC", + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" + "accessor-fn": "1" }, "engines": { "node": ">=12" @@ -3917,7 +3858,6 @@ "version": "1.7.5", "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", - "dev": true, "license": "MIT", "dependencies": { "d3-selection": "2 - 3", @@ -3928,33 +3868,6 @@ "node": ">=12" } }, - "node_modules/force-graph": { - "version": "1.51.4", - "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", - "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "18 - 25", - "accessor-fn": "1", - "bezier-js": "3 - 6", - "canvas-color-tracker": "^1.3", - "d3-array": "1 - 3", - "d3-drag": "2 - 3", - "d3-force-3d": "2 - 3", - "d3-scale": "1 - 4", - "d3-scale-chromatic": "1 - 3", - "d3-selection": "2 - 3", - "d3-zoom": "2 - 3", - "float-tooltip": "^1.7", - "index-array-by": "1", - "kapsule": "^1.16", - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4112,21 +4025,10 @@ "node": ">=8" } }, - "node_modules/index-array-by": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", - "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4228,7 +4130,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4238,7 +4139,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsdom": { @@ -4343,7 +4243,6 @@ "version": "1.16.3", "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", - "dev": true, "license": "MIT", "dependencies": { "lodash-es": "4" @@ -4699,7 +4598,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, "license": "MIT" }, "node_modules/log-update": { @@ -4792,7 +4690,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4951,6 +4848,44 @@ "dev": true, "license": "MIT" }, + "node_modules/ngraph.events": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", + "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", + "license": "BSD-3-Clause" + }, + "node_modules/ngraph.forcelayout": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz", + "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.0.0", + "ngraph.merge": "^1.0.0", + "ngraph.random": "^1.0.0" + } + }, + "node_modules/ngraph.graph": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz", + "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.4.0" + } + }, + "node_modules/ngraph.merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz", + "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==", + "license": "MIT" + }, + "node_modules/ngraph.random": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz", + "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==", + "license": "BSD-3-Clause" + }, "node_modules/node-releases": { "version": "2.0.49", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", @@ -4965,7 +4900,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5111,6 +5045,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -5144,7 +5090,6 @@ "version": "10.29.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -5210,7 +5155,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5222,7 +5166,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -5239,7 +5182,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -5260,14 +5202,13 @@ "react": "^19.2.7" } }, - "node_modules/react-force-graph-2d": { + "node_modules/react-force-graph-3d": { "version": "1.29.1", - "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", - "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz", + "integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==", "license": "MIT", "dependencies": { - "force-graph": "^1.51", + "3d-force-graph": "^1.79", "prop-types": "15", "react-kapsule": "^2.5" }, @@ -5289,7 +5230,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", - "dev": true, "license": "MIT", "dependencies": { "jerrypick": "^1.1.2" @@ -5583,6 +5523,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, "license": "MIT" }, "node_modules/supports-color": { @@ -5621,6 +5562,56 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT", + "peer": true + }, + "node_modules/three-forcegraph": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.4.tgz", + "integrity": "sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "d3-array": "1 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "data-bind-mapper": "1", + "kapsule": "^1.16", + "ngraph.forcelayout": "3", + "ngraph.graph": "20", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.118.3" + } + }, + "node_modules/three-render-objects": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz", + "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5632,7 +5623,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "dev": true, "license": "MIT" }, "node_modules/tinyexec": { @@ -6023,6 +6013,7 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, "license": "MIT" }, "node_modules/w3c-xmlserializer": { diff --git a/package.json b/package.json index b86fc55..4abe37c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "papercache", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", @@ -33,10 +33,15 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-global-shortcut": "^2.3.2", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-window-state": "^2.4.1", - "expr-eval": "^2.0.2" + "@types/d3-force": "^3.0.10", + "d3-force": "^3.0.0", + "expr-eval": "^2.0.2", + "react-force-graph-3d": "^1.29.1", + "three": "^0.184.0" }, "devDependencies": { "@codemirror/commands": "^6.10.3", @@ -70,7 +75,6 @@ "prettier": "^3.8.3", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-force-graph-2d": "^1.29.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb091d9..2cdab6b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -715,7 +715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -2374,6 +2374,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2520,6 +2534,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2898,7 +2926,7 @@ dependencies = [ [[package]] name = "papercache" -version = "0.4.0" +version = "0.5.3" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2906,7 +2934,7 @@ dependencies = [ "dirs 5.0.1", "keyring", "objc", - "rand", + "rand 0.8.6", "reqwest 0.11.27", "serde", "serde_json", @@ -2915,6 +2943,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -3046,7 +3075,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -3201,6 +3230,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -3238,8 +3276,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3249,7 +3297,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3261,6 +3319,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4436,6 +4503,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4606,6 +4692,18 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5883ed..4e04991 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "papercache" -version = "0.4.0" +version = "0.5.3" description = "A PaperCache Tauri App" authors = ["Aditya Sharma"] edition = "2021" @@ -27,6 +27,7 @@ dirs = "5.0" aes-gcm = "0.10" rand = "0.8" base64 = "0.22" +tauri-plugin-notification = "2.0.0-rc.5" [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.25" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2806c99..336b855 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "window-state:default", "autostart:default", "updater:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "notification:default" ] } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index f266997..2cb201c 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -205,8 +205,16 @@ pub fn run_onboarding(app: &AppHandle) { let welcome_path = base.join("Welcome.md"); let welcome_content = format!( - "# Welcome to PaperCache\n\nThis is your first note. Start typing to edit it, or use {} + Shift + N to create a new one!", - mod_key + "# Welcome to PaperCache\n\nThis is your first note. Here's what you can do:\n\n\ + - **New note** — Press {} + N (or {} + Shift + N from anywhere)\n\ + - **Search notes** — Press {} + P\n\ + - **Graph view** — Press {} + G to see how your notes connect\n\ + - **Tasks & timers** — Press {} + T\n\ + - **Shortcuts** — Press {} + / for the full list\n\ + - **Slash commands** — Type `/` in the editor for AI, checkboxes, tasks, variables, and more\n\ + - **Settings** — Press {} + Shift + S\n\n\ + Start typing to edit this note, or create a new one!", + mod_key, mod_key, mod_key, mod_key, mod_key, mod_key, mod_key ); if !welcome_path.exists() { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 124ab7b..61e2bc3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod ai; pub mod fs; pub mod keychain; +pub mod notifications; pub mod shortcuts; pub mod system; diff --git a/src-tauri/src/commands/notifications.rs b/src-tauri/src/commands/notifications.rs new file mode 100644 index 0000000..fb6718b --- /dev/null +++ b/src-tauri/src/commands/notifications.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_notification::NotificationExt; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct ReminderPayload { + pub key: String, + pub label: String, + #[serde(rename = "dueAt")] + pub due_at: i64, // Unix timestamp in milliseconds +} + +#[derive(Default)] +pub struct NotificationState { + pub reminder_handles: Arc>>>, + pub timer_handles: Arc>>>, +} + +#[tauri::command] +pub async fn schedule_reminders( + app: AppHandle, + reminders: Vec, + state: tauri::State<'_, NotificationState>, +) -> Result<(), String> { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + + // Cancel all existing reminder handles + { + let mut handles = state + .reminder_handles + .write() + .map_err(|e| e.to_string())?; + for (_, handle) in handles.drain() { + handle.abort(); + } + } + + // Schedule new reminders + for reminder in reminders { + let delay_ms = reminder.due_at - now_ms; + if delay_ms < 0 { + // Already past – skip (already notified logic is on frontend) + continue; + } + + let app_clone = app.clone(); + let label = reminder.label.clone(); + let key = reminder.key.clone(); + + let handle = tokio::spawn(async move { + sleep(Duration::from_millis(delay_ms as u64)).await; + + let _ = app_clone + .notification() + .builder() + .title("PaperCache Reminder") + .body(&label) + .show(); + + let _ = app_clone.emit("reminder-fired", &key); + }); + + state + .reminder_handles + .write() + .map_err(|e| e.to_string())? + .insert(reminder.key, handle); + } + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_all_reminders( + state: tauri::State<'_, NotificationState>, +) -> Result<(), String> { + let mut handles = state + .reminder_handles + .write() + .map_err(|e| e.to_string())?; + for (_, handle) in handles.drain() { + handle.abort(); + } + Ok(()) +} + +#[tauri::command] +pub async fn schedule_timer( + app: AppHandle, + id: String, + duration_ms: u64, + label: String, + state: tauri::State<'_, NotificationState>, +) -> Result<(), String> { + // Cancel any existing timer with the same id + { + let mut handles = state + .timer_handles + .write() + .map_err(|e| e.to_string())?; + if let Some(existing) = handles.remove(&id) { + existing.abort(); + } + } + + let app_clone = app.clone(); + let id_clone = id.clone(); + + let handle = tokio::spawn(async move { + sleep(Duration::from_millis(duration_ms)).await; + + let _ = app_clone + .notification() + .builder() + .title("PaperCache Timer") + .body(&format!("⏱ Timer finished: {}", label)) + .show(); + + let _ = app_clone.emit("timer-complete", &id_clone); + }); + + state + .timer_handles + .write() + .map_err(|e| e.to_string())? + .insert(id, handle); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_timer( + id: String, + state: tauri::State<'_, NotificationState>, +) -> Result<(), String> { + let mut handles = state + .timer_handles + .write() + .map_err(|e| e.to_string())?; + if let Some(handle) = handles.remove(&id) { + handle.abort(); + } + Ok(()) +} diff --git a/src-tauri/src/commands/shortcuts.rs b/src-tauri/src/commands/shortcuts.rs index 005d85a..7887370 100644 --- a/src-tauri/src/commands/shortcuts.rs +++ b/src-tauri/src/commands/shortcuts.rs @@ -39,7 +39,18 @@ pub fn update_global_shortcut( app.global_shortcut() .on_shortcut(shortcut, move |app, _shortcut, event| { if event.state() == ShortcutState::Pressed { - crate::commands::system::toggle_window(app); + if action_clone == "new-note" { + if let Some(window) = app.get_webview_window("main") { + if !window.is_visible().unwrap_or(false) { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::macos::force_focus(); + } + } + } else { + crate::commands::system::toggle_window(app); + } let _ = app.emit(&format!("trigger-{}", action_clone), ()); } }) @@ -73,7 +84,18 @@ pub fn resume_shortcuts(app: AppHandle) -> Result<(), String> { .global_shortcut() .on_shortcut(shortcut, move |app, _, event| { if event.state() == ShortcutState::Pressed { - crate::commands::system::toggle_window(app); + if action_clone == "new-note" { + if let Some(window) = app.get_webview_window("main") { + if !window.is_visible().unwrap_or(false) { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::macos::force_focus(); + } + } + } else { + crate::commands::system::toggle_window(app); + } let _ = app.emit(&format!("trigger-{}", action_clone), ()); } }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 168249f..9c4eb3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod tray; use commands::shortcuts::GlobalShortcutState; +use commands::notifications::NotificationState; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -29,8 +30,10 @@ pub fn run() { tauri::Builder::default() .manage(GlobalShortcutState::default()) .manage(DialogState::default()) + .manage(NotificationState::default()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin( tauri_plugin_window_state::Builder::default() .with_state_flags( @@ -65,20 +68,40 @@ pub fn run() { let dialog_state = app.state::(); let is_dialog_open = dialog_state.is_open.clone(); + #[cfg(not(target_os = "macos"))] + let pending_hide = Arc::new(AtomicBool::new(false)); window.on_window_event({ let w = window.clone(); + #[cfg(not(target_os = "macos"))] + let pending = pending_hide.clone(); move |event| match event { tauri::WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = w.hide(); } - tauri::WindowEvent::Focused(focused) - if !focused && !is_dialog_open.load(Ordering::SeqCst) => - { - let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() || std::env::var("HYPRLAND_CMD").is_ok(); - if !is_hyprland { + tauri::WindowEvent::Focused(focused) => { + if focused { + #[cfg(not(target_os = "macos"))] + pending.store(false, Ordering::SeqCst); + } else if !is_dialog_open.load(Ordering::SeqCst) { + #[cfg(target_os = "macos")] let _ = w.hide(); + + #[cfg(not(target_os = "macos"))] + { + pending.store(true, Ordering::SeqCst); + let w2 = w.clone(); + let p2 = pending.clone(); + std::thread::spawn(move || { + std::thread::sleep( + std::time::Duration::from_millis(200), + ); + if p2.swap(false, Ordering::SeqCst) { + let _ = w2.hide(); + } + }); + } } } _ => {} @@ -120,6 +143,10 @@ pub fn run() { commands::shortcuts::update_global_shortcut, commands::shortcuts::pause_shortcuts, commands::shortcuts::resume_shortcuts, + commands::notifications::schedule_reminders, + commands::notifications::cancel_all_reminders, + commands::notifications::schedule_timer, + commands::notifications::cancel_timer, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f476774..3526acd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/2.0.0/tauri.schema.json", "productName": "PaperCache", - "version": "0.5.2", + "version": "0.5.3", "identifier": "com.variablethe.papercache", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 8a93e85..9684264 100644 --- a/src/App.css +++ b/src/App.css @@ -885,3 +885,301 @@ body { color: rgba(76, 29, 149, 0.7); } } + +/* ========== Toast Animation ========== */ +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ========== Timers Panel ========== */ +.timers-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; +} + +.timers-panel { + background: var(--bg-color, #fff); + color: var(--text-color, #333); + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); + width: min(480px, 95vw); + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: inherit; +} + +.timers-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); +} + +.timers-header h2 { + margin: 0; + font-size: 17px; + font-weight: 700; + opacity: 1; + display: flex; + align-items: center; +} + +.timers-close-btn { + background: transparent; + border: none; + color: inherit; + opacity: 0.5; + cursor: pointer; + font-size: 13px; + font-family: inherit; +} + +.timers-close-btn:hover { + opacity: 1; +} + +.timer-create-section { + padding: 16px 24px; + border-bottom: 1px solid rgba(128, 128, 128, 0.12); + display: flex; + flex-direction: column; + gap: 10px; +} + +.timer-label-input { + width: 100%; + background: rgba(128, 128, 128, 0.08); + border: 1px solid rgba(128, 128, 128, 0.2); + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + color: inherit; + font-family: inherit; + box-sizing: border-box; + outline: none; +} + +.timer-label-input:focus { + border-color: rgba(128, 128, 128, 0.4); +} + +.timer-duration-row { + display: flex; + align-items: center; + gap: 8px; +} + +.timer-duration-field { + display: flex; + align-items: center; + gap: 4px; + flex: 1; +} + +.timer-duration-field input { + width: 100%; + background: rgba(128, 128, 128, 0.08); + border: 1px solid rgba(128, 128, 128, 0.2); + border-radius: 6px; + padding: 8px 6px; + font-size: 15px; + color: inherit; + font-family: inherit; + text-align: center; + outline: none; + box-sizing: border-box; +} + +.timer-duration-field span { + font-size: 12px; + opacity: 0.5; + min-width: 10px; +} + +.timer-start-btn { + padding: 8px 18px; + background: var(--text-color, #333); + color: var(--bg-color, #fff); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-family: inherit; + font-weight: 600; + white-space: nowrap; + transition: opacity 0.15s; +} + +.timer-start-btn:hover { + opacity: 0.8; +} + +.timer-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.timer-preset-btn { + padding: 5px 12px; + background: rgba(128, 128, 128, 0.1); + border: 1px solid rgba(128, 128, 128, 0.2); + border-radius: 20px; + cursor: pointer; + font-size: 12px; + color: inherit; + font-family: inherit; + transition: background 0.15s; +} + +.timer-preset-btn:hover { + background: rgba(128, 128, 128, 0.2); +} + +.timers-list { + flex: 1; + overflow-y: auto; + padding: 12px 24px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.timers-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + opacity: 0.5; + text-align: center; + gap: 6px; +} + +.timers-empty-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.timers-empty-hint { + font-size: 12px; + opacity: 0.7; + margin: 0; +} + +.timers-empty-hint code { + background: rgba(99, 102, 241, 0.12); + color: #6366f1; + border: 1px solid rgba(99, 102, 241, 0.3); + padding: 2px 7px; + border-radius: 4px; + font-family: 'SF Mono', 'Fira Mono', 'Cascadia Code', monospace; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.timer-item { + border: 1px solid rgba(128, 128, 128, 0.15); + border-radius: 10px; + padding: 14px 16px; + background: rgba(128, 128, 128, 0.04); + transition: background 0.2s; +} + +.timer-item.timer-completed { + opacity: 0.6; + border-color: rgba(16, 185, 129, 0.3); + background: rgba(16, 185, 129, 0.05); +} + +.timer-item.timer-paused { + border-color: rgba(245, 158, 11, 0.3); + background: rgba(245, 158, 11, 0.04); +} + +.timer-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.timer-label { + font-size: 14px; + font-weight: 600; +} + +.timer-controls { + display: flex; + gap: 6px; +} + +.timer-btn { + background: rgba(128, 128, 128, 0.1); + border: none; + border-radius: 4px; + color: inherit; + cursor: pointer; + padding: 3px 8px; + font-size: 12px; + transition: background 0.15s; +} + +.timer-btn:hover { + background: rgba(128, 128, 128, 0.2); +} + +.timer-btn-remove { + opacity: 0.5; + font-size: 16px; + line-height: 1; +} + +.timer-btn-remove:hover { + opacity: 1; + background: rgba(239, 68, 68, 0.15); +} + +.timer-countdown { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.5px; + margin-bottom: 10px; +} + +.timer-progress-track { + height: 4px; + background: rgba(128, 128, 128, 0.15); + border-radius: 2px; + overflow: hidden; +} + +.timer-progress-fill { + height: 100%; + background: var(--text-color, #333); + border-radius: 2px; + transition: width 0.25s linear; +} + +.timer-progress-fill.timer-progress-done { + background: #10b981; + width: 100% !important; +} diff --git a/src/App.tsx b/src/App.tsx index b5f8dc3..53b5cdf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ -import { useRef, useEffect } from 'react' +import { useRef, useEffect, lazy, Suspense } from 'react' import { getVersion } from '@tauri-apps/api/app' import './App.css' -import GraphView from './GraphView' +const GraphView = lazy(() => import('./GraphView')) import { RemindersPage } from './components/RemindersPage' +import { TimersPage } from './components/TimersPage' import { useAppStore } from './store/useAppStore' import { useSettingsStore } from './store/useSettingsStore' @@ -28,6 +29,10 @@ function App() { const setShowGraphView = useAppStore((state) => state.setShowGraphView) const showRemindersView = useAppStore((state) => state.showRemindersView) const setShowRemindersView = useAppStore((state) => state.setShowRemindersView) + const showTimersView = useAppStore((state) => state.showTimersView) + 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) @@ -53,6 +58,14 @@ function App() { }) }, []) + // Auto-dismiss toasts after 5 seconds + useEffect(() => { + if (toasts.length === 0) return undefined + const ids = toasts.map((t) => t.id) + const timers = ids.map((id) => setTimeout(() => removeToast(id), 5000)) + return () => timers.forEach(clearTimeout) + }, [toasts, removeToast]) + useEffect(() => { if (showNoteSearch && searchInputRef.current) { setTimeout(() => { @@ -66,7 +79,17 @@ function App() { if (notes.length === 0) return const currentVersion = await getVersion() const lastSeenVersion = localStorage.getItem('papercache-last-seen-version') - if (lastSeenVersion !== currentVersion) { + + if (lastSeenVersion === null) { + localStorage.setItem('papercache-last-seen-version', currentVersion) + const welcomeIndex = notes.findIndex((n) => { + const filename = n.id.split('/').pop() || '' + return filename === 'Welcome.md' + }) + if (welcomeIndex !== -1) { + setCurrentNoteIndex(welcomeIndex) + } + } else if (lastSeenVersion !== currentVersion) { localStorage.setItem('papercache-last-seen-version', currentVersion) const targetId = `New Features in v${currentVersion}.md` const targetIndex = notes.findIndex((n) => { @@ -146,21 +169,25 @@ function App() { + {showTimersView && setShowTimersView(false)} />} + {showGraphView && ( - setShowGraphView(false)} - textColor={textColor} - bgColor={bgColor} - accentColor={numColor} - onNodeClick={(nodeId) => { - const index = notes.findIndex((n) => n.id === nodeId) - if (index !== -1) { - setCurrentNoteIndex(index) - setShowGraphView(false) - } - }} - /> + + setShowGraphView(false)} + textColor={textColor} + bgColor={bgColor} + accentColor={numColor} + onNodeClick={(nodeId) => { + const index = notes.findIndex((n) => n.id === nodeId) + if (index !== -1) { + setCurrentNoteIndex(index) + setShowGraphView(false) + } + }} + /> + )} @@ -182,6 +209,49 @@ function App() { setShowSettingsModal(false)} /> )} + + {/* In-app toast notifications */} + {toasts.length > 0 && ( +
+ {toasts.map((toast) => ( +
removeToast(toast.id)} + style={{ + padding: '10px 16px', + borderRadius: 8, + background: + toast.type === 'success' + ? 'rgba(16, 185, 129, 0.95)' + : toast.type === 'error' + ? 'rgba(239, 68, 68, 0.95)' + : toast.type === 'warning' + ? 'rgba(245, 158, 11, 0.95)' + : 'rgba(59, 130, 246, 0.95)', + color: '#fff', + fontSize: 13, + fontFamily: fontFamily, + boxShadow: '0 4px 12px rgba(0,0,0,0.2)', + cursor: 'pointer', + maxWidth: 320, + animation: 'toast-in 0.25s ease', + }} + > + {toast.message} +
+ ))} +
+ )} ) } diff --git a/src/GraphView.tsx b/src/GraphView.tsx index 7b68381..2be9337 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -1,8 +1,12 @@ -import { useMemo, useCallback, useEffect } from 'react' -import ForceGraph2D from 'react-force-graph-2d' - +import { useMemo, useCallback, useEffect, useRef, useState, lazy, Suspense } from 'react' +import * as THREE from 'three' +import * as d3 from 'd3-force' import { getFolderColor } from './utils' +const ForceGraph3D = lazy(() => import('react-force-graph-3d')) + +const nodePositionsCache = new Map() + interface GraphViewProps { notes: { id: string; content: string; mtime: number }[] onClose: () => void @@ -12,6 +16,36 @@ interface GraphViewProps { accentColor: string } +interface GraphNode { + id: string + name: string + val: number + folder: string + x?: number + y?: number + z?: number +} + +interface GraphLink { + source: string + target: string +} + +function buildFolderCentroids(folderNames: string[]): Map { + const centroids = new Map() + const n = folderNames.length + if (n === 0) return centroids + const radius = 60 + folderNames.forEach((folder, i) => { + const angle = (2 * Math.PI * i) / n - Math.PI / 2 + centroids.set(folder, { + cx: radius * Math.cos(angle), + cy: radius * Math.sin(angle), + }) + }) + return centroids +} + export default function GraphView({ notes, onClose, @@ -20,9 +54,60 @@ export default function GraphView({ bgColor, accentColor, }: GraphViewProps) { - // Parse links + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fgRef = useRef(null) + + const draggedNodesRef = useRef>(new Set()) + + useEffect(() => { + let raf: number + let ctrls: any = null + const setup = () => { + const fg = fgRef.current + if (!fg) { + raf = requestAnimationFrame(setup) + return + } + ctrls = fg.controls() + if (!ctrls) { + raf = requestAnimationFrame(setup) + return + } + ctrls.enableRotate = false + ctrls.enablePan = true + ctrls.enableZoom = true + ctrls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: null } + ctrls.touches = { ONE: 1, TWO: 2 } + ctrls.zoomSpeed = 6 + ctrls.panSpeed = 0.15 + ctrls.update() + fg.cameraPosition({ x: 0, y: 0, z: 500 }) + setTimeout(() => fg.zoomToFit(400, 50), 300) + } + raf = requestAnimationFrame(setup) + return () => cancelAnimationFrame(raf) + }, []) + + useEffect(() => { + return () => { + const fg = fgRef.current + if (!fg) return + const data = fg.graphData() + if (!data || !data.nodes) return + data.nodes.forEach((node: GraphNode) => { + if (node.x != null && node.y != null) { + nodePositionsCache.set(node.id, { x: node.x, y: node.y }) + } + }) + } + }, []) + + const handleNodeDragEnd = useCallback((node: GraphNode) => { + draggedNodesRef.current.add(node.id) + }, []) + const graphData = useMemo(() => { - const nodes = notes.map((n) => { + const nodes: GraphNode[] = notes.map((n) => { const isAuto = /^\d+\.md$/.test(n.id) let title = n.id.replace(/\.md$/, '') const folder = n.id.includes('/') ? n.id.split('/')[0] : '' @@ -34,26 +119,21 @@ export default function GraphView({ .trim() .replace(/^#+\s*/, '') || 'New Note' } - return { id: n.id, name: title, val: 1, folder } + const cached = nodePositionsCache.get(n.id) + return { id: n.id, name: title, val: 1, folder, x: cached?.x, y: cached?.y } }) - const links: { source: string; target: string }[] = [] + const links: GraphLink[] = [] const nodeIds = new Set(nodes.map((n) => n.id)) notes.forEach((note) => { - // Find links like `](/file id)` or `](/file id.md)` const re = /\]\(\/file\s+([^)]+)\)/g let match while ((match = re.exec(note.content)) !== null) { let targetId = match[1] if (!targetId.endsWith('.md')) targetId += '.md' - - // Only add link if target exists if (nodeIds.has(targetId)) { - links.push({ - source: note.id, - target: targetId, - }) + links.push({ source: note.id, target: targetId }) } } }) @@ -61,98 +141,305 @@ export default function GraphView({ return { nodes, links } }, [notes]) + useEffect(() => { + const fg = fgRef.current + if (!fg) return + + const folders = Array.from(new Set(graphData.nodes.map((n) => n.folder).filter(Boolean))) + const centroids = buildFolderCentroids(folders) + + fg.d3Force('centerX', d3.forceX(0).strength(0.008)) + + fg.d3Force('centerY', d3.forceY(0).strength(0.008)) + + fg.d3Force( + 'folderX', + d3 + .forceX((node) => { + const c = centroids.get(node.folder) + return c ? c.cx : 0 + }) + .strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0)) + ) + + fg.d3Force( + 'folderY', + d3 + .forceY((node) => { + const c = centroids.get(node.folder) + return c ? c.cy : 0 + }) + .strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0)) + ) + + fg.d3Force('charge')?.strength(-120) + + fg.d3Force('collision', d3.forceCollide(22)) + + fg.d3ReheatSimulation() + }, [graphData]) + const handleNodeClick = useCallback( - (node: { id: string; name: string; val: number; folder: string }) => { + (node: GraphNode) => { onNodeClick(node.id) }, [onNodeClick] ) + // Search + const [showSearch, setShowSearch] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searchIndex, setSearchIndex] = useState(0) + const searchRef = useRef(null) + + const focusOnNode = useCallback((nodeId: string) => { + const fg = fgRef.current + if (!fg) return + const node = fg.graphData().nodes.find((n: GraphNode) => n.id === nodeId) + if (!node || node.x == null || node.y == null) return + fg.centerAt(node.x, node.y, 400) + setTimeout(() => { + fg.cameraPosition({ x: node.x, y: node.y, z: 120 }, { x: node.x, y: node.y, z: 0 }, 400) + }, 200) + }, []) + + const nodeThreeObject = useCallback( + (node: GraphNode) => { + const color = node.folder ? getFolderColor(node.folder) : accentColor + const group = new THREE.Group() + + const geometry = new THREE.CircleGeometry(12, 32) + const material = new THREE.MeshBasicMaterial({ + color, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.99, + depthWrite: true, + }) + const circle = new THREE.Mesh(geometry, material) + circle.renderOrder = 1 + group.add(circle) + + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 64 + const ctx = canvas.getContext('2d')! + ctx.clearRect(0, 0, 256, 64) + const displayName = node.name.length > 20 ? node.name.slice(0, 17) + '…' : node.name + ctx.fillStyle = textColor + ctx.font = 'bold 20px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(displayName, 128, 32) + const tex = new THREE.CanvasTexture(canvas) + const labelMat = new THREE.SpriteMaterial({ + map: tex, + transparent: true, + depthWrite: false, + depthTest: false, + }) + const label = new THREE.Sprite(labelMat) + label.scale.set(36, 9, 1) + label.position.set(0, -15, 0) + label.renderOrder = 2 + group.add(label) + + return group + }, + [accentColor, textColor] + ) + + function fuzzyMatch(text: string, query: string): boolean { + const t = text.toLowerCase() + const q = query.toLowerCase().trim() + if (!q) return false + let qi = 0 + for (const ch of t) { + if (ch === q[qi]) qi++ + if (qi === q.length) return true + } + return false + } + + useEffect(() => { + if (!showSearch) return + const filtered = graphData.nodes.filter((n) => fuzzyMatch(n.name, searchQuery)) + setSearchResults(filtered) + setSearchIndex(0) + }, [searchQuery, showSearch, graphData.nodes]) + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSearchIndex((i) => Math.min(i + 1, searchResults.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSearchIndex((i) => Math.max(i - 1, 0)) + } else if (e.key === 'Enter' && searchResults[searchIndex]) { + const node = searchResults[searchIndex] + focusOnNode(node.id) + } + }, + [searchResults, searchIndex, focusOnNode] + ) + + useEffect(() => { + if (showSearch && searchRef.current) searchRef.current.focus() + }, [showSearch]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose() + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault() + setShowSearch(true) + } else if (e.key === 'Escape') { + if (showSearch) { + setShowSearch(false) + setSearchQuery('') + } else { + onClose() + } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [onClose]) + }, [onClose, showSearch]) return ( -
+ <> +
-

- Graph View -

- -
-
- accentColor} - linkColor={() => `${textColor}44`} - backgroundColor={bgColor} - onNodeClick={handleNodeClick} - nodeRelSize={6} - linkWidth={2} - nodeCanvasObject={( - node: { id: string; name: string; val: number; folder: string; x?: number; y?: number }, - ctx, - globalScale - ) => { - const label = node.name - const fontSize = 12 / globalScale - ctx.font = `${fontSize}px Sans-Serif` - - ctx.beginPath() - ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false) - ctx.fillStyle = node.folder ? getFolderColor(node.folder) : accentColor - ctx.fill() - - if (globalScale >= 1.5) { - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillStyle = textColor - ctx.fillText(label, node.x as number, (node.y as number) + 10) + {showSearch ? ( +
+ setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search notes…" + style={{ + flex: 1, + background: `${textColor}11`, + border: `1px solid ${textColor}33`, + borderRadius: 6, + padding: '6px 10px', + color: textColor, + fontSize: 14, + outline: 'none', + fontFamily: 'inherit', + }} + /> + + {searchResults.length > 0 + ? `${searchIndex + 1}/${searchResults.length}` + : searchQuery + ? '0 matches' + : ''} + +
+ ) : ( +

+ Graph View + + WebGL + +

+ )} + +
+ +
+ + Loading graph… +
} - }} - /> + > + + (node as GraphNode).folder + ? getFolderColor((node as GraphNode).folder) + : accentColor + } + linkColor={() => `${textColor}55`} + backgroundColor={bgColor} + onNodeClick={handleNodeClick as (node: object) => void} + onNodeDragEnd={handleNodeDragEnd as (node: object) => void} + nodeThreeObject={nodeThreeObject} + nodeRelSize={6} + linkWidth={1.5} + linkOpacity={0.6} + enableNodeDrag={true} + enableNavigationControls={true} + showNavInfo={false} + /> + +
- + ) } diff --git a/src/api.ts b/src/api.ts index 02146db..953ecfc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -23,6 +23,11 @@ export const tauriApi: ElectronAPI = { quitApp: () => invoke('quit_app'), openExternal: (url) => invoke('open_external', { url }), openFile: (path) => invoke('open_file', { path }), + scheduleReminders: (reminders) => + invoke('schedule_reminders', { reminders: reminders as unknown[] }), + cancelReminders: () => invoke('cancel_all_reminders'), + scheduleTimer: (id, durationMs, label) => invoke('schedule_timer', { id, durationMs, label }), + cancelTimer: (id) => invoke('cancel_timer', { id }), // Phase 3+ Stubs openAIChat: (args) => invoke('openai_chat', args), diff --git a/src/components/MainActionMenu.tsx b/src/components/MainActionMenu.tsx index d1fbd6f..7fa5ce4 100644 --- a/src/components/MainActionMenu.tsx +++ b/src/components/MainActionMenu.tsx @@ -8,6 +8,7 @@ export function MainActionMenu() { const setShowNoteSearch = useAppStore((state) => state.setShowNoteSearch) const setShowGraphView = useAppStore((state) => state.setShowGraphView) const setShowRemindersView = useAppStore((state) => state.setShowRemindersView) + const setShowTimersView = useAppStore((state) => state.setShowTimersView) const setShowSettingsModal = useAppStore((state) => state.setShowSettingsModal) const { bgType, bgColor, textColor, fontFamily } = useSettingsStore() @@ -82,6 +83,18 @@ export function MainActionMenu() { > Tasks + + )} + + + + +
+ {isCompleted ? '✓ Done' : formatTime(timer.remainingMs)} +
+ +
+
+
+
+ ) +} + +interface TimersPageProps { + onClose: () => void +} + +const QUICK_PRESETS = [ + { label: '5 min', ms: 5 * 60 * 1000 }, + { label: '10 min', ms: 10 * 60 * 1000 }, + { label: '25 min', ms: 25 * 60 * 1000 }, + { label: '1 hr', ms: 60 * 60 * 1000 }, +] + +export function TimersPage({ onClose }: TimersPageProps) { + const timers = useTimerStore((s) => s.timers) + const addTimer = useTimerStore((s) => s.addTimer) + const removeTimer = useTimerStore((s) => s.removeTimer) + const pauseTimer = useTimerStore((s) => s.pauseTimer) + const resumeTimer = useTimerStore((s) => s.resumeTimer) + const completeTimer = useTimerStore((s) => s.completeTimer) + const addToast = useAppStore((s) => s.addToast) + + const [labelInput, setLabelInput] = useState('') + const [hInput, setHInput] = useState('0') + const [mInput, setMInput] = useState('25') + const [sInput, setSInput] = useState('0') + + // Listen for backend timer-complete events + useEffect(() => { + let unlisten: (() => void) | undefined + listen('timer-complete', (event) => { + const id = event.payload + completeTimer(id) + const t = useTimerStore.getState().timers.find((x) => x.id === id) + addToast({ message: `⏱ Timer done: ${t?.label || ''}`, type: 'success' }) + }).then((fn) => { + unlisten = fn + }) + return () => unlisten?.() + }, [completeTimer, addToast]) + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [onClose]) + + const handleCreate = () => { + const h = parseInt(hInput) || 0 + const m = parseInt(mInput) || 0 + const s = parseInt(sInput) || 0 + const durationMs = (h * 3600 + m * 60 + s) * 1000 + if (durationMs <= 0) return + + const label = + labelInput.trim() || + `${h > 0 ? `${h}h ` : ''}${m > 0 ? `${m}m ` : ''}${s > 0 ? `${s}s` : ''}`.trim() + const id = addTimer(label, durationMs) + window.electronAPI.scheduleTimer(id, durationMs, label) + setLabelInput('') + } + + const handleRemove = (id: string) => { + window.electronAPI.cancelTimer(id).catch(() => {}) + removeTimer(id) + } + + const handlePreset = (ms: number, presetLabel: string) => { + const id = addTimer(presetLabel, ms) + window.electronAPI.scheduleTimer(id, ms, presetLabel) + } + + return ( +
+
e.stopPropagation()}> +
+

+ + + + + + + Timers +

+ +
+ + {/* Create new timer */} +
+ setLabelInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ setHInput(e.target.value)} + /> + h +
+
+ setMInput(e.target.value)} + /> + m +
+
+ setSInput(e.target.value)} + /> + s +
+ +
+
+ {QUICK_PRESETS.map((p) => ( + + ))} +
+
+ + {/* Active timers list */} +
+ {timers.length === 0 ? ( +
+
+

No active timers

+

+ Use /timer in any note or create one above. +

+
+ ) : ( + timers.map((t) => ( + + )) + )} +
+
+
+ ) +} diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts index 10ee193..285936c 100644 --- a/src/hooks/useGlobalHotkey.ts +++ b/src/hooks/useGlobalHotkey.ts @@ -101,6 +101,48 @@ export function useGlobalHotkey() { e.stopPropagation() setShowMainActionMenu((prev) => !prev) } + + // Shortcuts reference + if ((e.key === '/' || e.key === '?') && isMod) { + e.preventDefault() + e.stopPropagation() + const { notes } = useAppStore.getState() + const existingIndex = notes.findIndex((n) => n.id === 'Shortcuts.md') + if (existingIndex !== -1) { + setCurrentNoteIndex(existingIndex) + } else { + const shortcutsContent = `# Shortcuts + +| Shortcut | Action | +|----------|--------| +| \`Cmd+Shift+C\` | Toggle visibility (global, configurable) | +| \`Cmd+Shift+N\` | New note (global, configurable) | +| \`Cmd+Shift+S\` | Open settings | +| \`Cmd+N\` | New note | +| \`Cmd+T\` | Tasks / Reminders | +| \`Cmd+K\` | Main action menu | +| \`Cmd+P\` | Search notes | +| \`Cmd+G\` | Graph view | +| \`Cmd+F\` | Search in graph | +| \`Cmd+E\` | Export note | +| \`Cmd+/\` | Show this shortcuts reference | +| \`Esc\` | Close menus / modals | + +### Slash Commands +Type \`/\` in the editor for inline suggestions: +- \`/ai\` — AI prompt +- \`/check\` — Checkbox +- \`/task\` — Task with due date +- \`/var\` — Local variable +- \`/globvar\` — Global variable +- \`/ctx\` — Context note +` + const newNote = { id: 'Shortcuts.md', content: shortcutsContent, mtime: Date.now() } + setNotes((prev) => [newNote, ...prev]) + setCurrentNoteIndex(0) + window.electronAPI.saveNote('Shortcuts.md', shortcutsContent) + } + } } // Sync global shortcut on load diff --git a/src/hooks/useReminders.test.ts b/src/hooks/useReminders.test.ts index 08717f5..3fbc814 100644 --- a/src/hooks/useReminders.test.ts +++ b/src/hooks/useReminders.test.ts @@ -1,34 +1,31 @@ -import { renderHook } from '@testing-library/react' +import { renderHook, act } from '@testing-library/react' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { useReminders } from './useReminders' import { SETTINGS_KEYS } from '../lib/settingsKeys' import { useAppStore } from '../store/useAppStore' +// Mock @tauri-apps/api/event so the listen() call doesn't fail in jsdom +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})) + describe('useReminders', () => { + const scheduleRemindersMock = vi.fn().mockResolvedValue(undefined) + const cancelRemindersMock = vi.fn().mockResolvedValue(undefined) + beforeEach(() => { vi.useFakeTimers() - // Mock Notification - globalThis.Notification = Object.assign(vi.fn(), { - requestPermission: vi.fn().mockResolvedValue('granted'), - permission: 'granted' as NotificationPermission, - }) - - // Mock electronAPI if not present - if (!window.electronAPI) { - window.electronAPI = { - onPowerSuspend: vi.fn().mockReturnValue(vi.fn()), - onPowerResume: vi.fn().mockReturnValue(vi.fn()), - } as unknown as Window['electronAPI'] - } + // Mock window.electronAPI with the new Rust-backed interface + window.electronAPI = { + scheduleReminders: scheduleRemindersMock, + cancelReminders: cancelRemindersMock, + onPowerSuspend: vi.fn().mockReturnValue(vi.fn()), + onPowerResume: vi.fn().mockReturnValue(vi.fn()), + } as unknown as Window['electronAPI'] - // Clear localStorage localStorage.clear() - - // Clear mocks vi.clearAllMocks() - - // Reset state useAppStore.setState({ notes: [] }) }) @@ -36,100 +33,101 @@ describe('useReminders', () => { vi.useRealTimers() }) - it('should request notification permission if not granted', () => { - Object.defineProperty(Notification, 'permission', { - value: 'default', - configurable: true, + it('should call scheduleReminders with future pending reminders on mount', async () => { + const d = new Date(Date.now() + 600000) + const pad = (n: number) => String(n).padStart(2, '0') + const futureDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + + await act(async () => { + useAppStore.setState({ + notes: [{ id: '1', content: `/task Buy bread @ ${futureDate}`, mtime: 0 }], + }) }) renderHook(() => useReminders()) - expect(Notification.requestPermission).toHaveBeenCalled() + + // Should have called the backend to schedule the reminder + expect(scheduleRemindersMock).toHaveBeenCalledTimes(1) + const reminders = scheduleRemindersMock.mock.calls[0][0] as { + key: string + label: string + dueAt: number + }[] + expect(reminders.length).toBe(1) + expect(reminders[0].label).toBe('Buy bread') + expect(reminders[0].dueAt).toBeGreaterThan(Date.now()) }) - it('should trigger notification for due reminders', () => { + it('should NOT schedule past-due reminders (already notified by backend on last run)', async () => { const d = new Date(Date.now() - 60000) const pad = (n: number) => String(n).padStart(2, '0') const pastDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` - useAppStore.setState({ - notes: [ - { - id: '1', - content: `/task Buy milk @ ${pastDate}`, - mtime: 0, - }, - ], + + await act(async () => { + useAppStore.setState({ + notes: [{ id: '1', content: `/task Buy milk @ ${pastDate}`, mtime: 0 }], + }) }) renderHook(() => useReminders()) - expect(Notification).toHaveBeenCalledWith('PaperCache Reminder', { - body: 'Buy milk', - silent: false, - }) - - // Check that it's saved in localStorage - const notified = JSON.parse(localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]') - expect(notified.length).toBe(1) + // Called but with empty array – past reminders are not re-scheduled + expect(scheduleRemindersMock).toHaveBeenCalledTimes(1) + const reminders = scheduleRemindersMock.mock.calls[0][0] as unknown[] + expect(reminders.length).toBe(0) }) - it('should schedule notification for future reminders', () => { - const d2 = new Date(Date.now() + 600000) - const pad2 = (n: number) => String(n).padStart(2, '0') - const futureDate = `${d2.getFullYear()}-${pad2(d2.getMonth() + 1)}-${pad2(d2.getDate())} ${pad2(d2.getHours())}:${pad2(d2.getMinutes())}` - useAppStore.setState({ - notes: [ - { - id: '1', - content: `/task (2025-01-01 10:00) Buy bread @ ${futureDate}`, - mtime: 0, - }, - ], + it('should not schedule notifications for completed tasks', async () => { + const d = new Date(Date.now() + 60000) + const pad = (n: number) => String(n).padStart(2, '0') + const futureDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + + await act(async () => { + useAppStore.setState({ + notes: [{ id: '1', content: `/task-done Buy milk @ ${futureDate}`, mtime: 0 }], + }) }) renderHook(() => useReminders()) - // Should not trigger yet - expect(Notification).not.toHaveBeenCalled() + const reminders = scheduleRemindersMock.mock.calls[0][0] as unknown[] + expect(reminders.length).toBe(0) + }) - // Fast-forward time - vi.advanceTimersByTime(605000) + it('should mark a reminder as notified when reminder-fired event is received', async () => { + const { listen } = await import('@tauri-apps/api/event') + let capturedCallback: ((e: { payload: string }) => void) | undefined - // Now it should trigger - expect(Notification).toHaveBeenCalledWith('PaperCache Reminder', { - body: 'Buy bread', - silent: false, + vi.mocked(listen).mockImplementation((_event, cb) => { + capturedCallback = cb as (e: { payload: string }) => void + return Promise.resolve(() => {}) }) - }) - it('should not trigger notification for completed tasks', () => { - const d = new Date(Date.now() - 60000) + const d = new Date(Date.now() + 600000) const pad = (n: number) => String(n).padStart(2, '0') - const pastDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` - useAppStore.setState({ - notes: [ - { - id: '1', - content: `/task-done Buy milk @ ${pastDate}`, - mtime: 0, - }, - ], + const futureDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + const reminderKey = `1-${d.getTime()}-Buy coffee` + + await act(async () => { + useAppStore.setState({ + notes: [{ id: '1', content: `/task Buy coffee @ ${futureDate}`, mtime: 0 }], + }) }) renderHook(() => useReminders()) - expect(Notification).not.toHaveBeenCalled() - }) - - it('should handle power suspend and resume', () => { - const suspendMock = vi.fn() - const resumeMock = vi.fn() - - window.electronAPI.onPowerSuspend = suspendMock.mockReturnValue(vi.fn()) - window.electronAPI.onPowerResume = resumeMock.mockReturnValue(vi.fn()) + // Simulate the backend firing the reminder + await act(async () => { + capturedCallback?.({ payload: reminderKey }) + }) - renderHook(() => useReminders()) + const notified = JSON.parse(localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]') + expect(notified).toContain(reminderKey) + }) - expect(suspendMock).toHaveBeenCalled() - expect(resumeMock).toHaveBeenCalled() + it('should call cancelReminders on unmount', () => { + const { unmount } = renderHook(() => useReminders()) + unmount() + expect(cancelRemindersMock).toHaveBeenCalled() }) }) diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts index 31993e3..b80e479 100644 --- a/src/hooks/useReminders.ts +++ b/src/hooks/useReminders.ts @@ -1,9 +1,17 @@ import { useEffect, useRef } 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' -function parseReminders(content: string, noteId: string) { - const reminders: { dueAt: Date; label: string; key: string }[] = [] + +interface ReminderPayload { + key: string + label: string + dueAt: number // Unix ms +} + +function parseReminders(content: string, noteId: string): ReminderPayload[] { + const reminders: ReminderPayload[] = [] const tasks = parseAllTasks(content) for (const task of tasks) { @@ -11,7 +19,7 @@ function parseReminders(content: string, noteId: string) { const targetMs = new Date(task.targetStr).getTime() if (!isNaN(targetMs)) { reminders.push({ - dueAt: new Date(targetMs), + dueAt: targetMs, label: task.label, key: `${noteId}-${targetMs}-${task.label}`, }) @@ -21,82 +29,61 @@ function parseReminders(content: string, noteId: string) { return reminders } -function handleDueReminders(notes: Note[]) { - const now = Date.now() +function collectFutureReminders(notes: Note[]): ReminderPayload[] { const notifiedStr = localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]' const notified = new Set(JSON.parse(notifiedStr)) - let hasNewNotifs = false - - const allReminders = notes.flatMap((n) => parseReminders(n.content, n.id)) - - for (const r of allReminders) { - if (now >= r.dueAt.getTime()) { - if (!notified.has(r.key)) { - new Notification('PaperCache Reminder', { - body: r.label, - silent: false, - }) - notified.add(r.key) - hasNewNotifs = true - } - } - } - - if (hasNewNotifs) { - localStorage.setItem(SETTINGS_KEYS.NOTIFIED_REMINDERS, JSON.stringify(Array.from(notified))) - } -} - -function scheduleNextReminder(notes: Note[], callback: () => void) { const now = Date.now() - const next = notes - .flatMap((n) => parseReminders(n.content, n.id)) - .map((r) => r.dueAt.getTime()) - .filter((t) => t > now) - .sort()[0] - - if (!next) return null - // Ensure delay is at least 1000ms to avoid tight loops if something goes wrong - const delay = Math.max(next - now, 1000) - return setTimeout(callback, delay) + return notes + .flatMap((n) => parseReminders(n.content, n.id)) + .filter((r) => r.dueAt > now && !notified.has(r.key)) } export function useReminders() { const notes = useAppStore((state) => state.notes) - const timerRef = useRef | null>(null) + // Track the notes reference so we can skip redundant invocations + const prevNotesRef = useRef([]) + // Schedule reminders in the Rust backend whenever notes change useEffect(() => { - if (Notification.permission !== 'granted' && Notification.permission !== 'denied') { - Notification.requestPermission() - } + prevNotesRef.current = notes + const pending = collectFutureReminders(notes) - const checkAndSchedule = () => { - handleDueReminders(notes) - if (timerRef.current) clearTimeout(timerRef.current) - timerRef.current = scheduleNextReminder(notes, checkAndSchedule) - } - - checkAndSchedule() + window.electronAPI + .scheduleReminders(pending) + // eslint-disable-next-line no-console + .catch((e) => console.error('Failed to schedule reminders', e)) + }, [notes]) - const onSuspend = () => { - if (timerRef.current) { - clearTimeout(timerRef.current) - timerRef.current = null + // Listen for the native "reminder-fired" event from the backend + useEffect(() => { + let unlisten: (() => void) | undefined + + listen('reminder-fired', (event) => { + const key = event.payload + // Mark reminder as notified in localStorage + const notifiedStr = localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]' + const notified = new Set(JSON.parse(notifiedStr)) + notified.add(key) + localStorage.setItem(SETTINGS_KEYS.NOTIFIED_REMINDERS, JSON.stringify(Array.from(notified))) + + // Find the label from current notes for the in-app toast + const currentNotes = useAppStore.getState().notes + const allReminders = currentNotes.flatMap((n) => parseReminders(n.content, n.id)) + const reminder = allReminders.find((r) => r.key === key) + if (reminder) { + useAppStore.getState().addToast({ + message: `🔔 Reminder: ${reminder.label}`, + type: 'info', + }) } - } - - const onResume = () => { - checkAndSchedule() - } - - const unsubscribeSuspend = window.electronAPI.onPowerSuspend(onSuspend) - const unsubscribeResume = window.electronAPI.onPowerResume(onResume) + }).then((fn) => { + unlisten = fn + }) return () => { - if (timerRef.current) clearTimeout(timerRef.current) - unsubscribeSuspend() - unsubscribeResume() + unlisten?.() + window.electronAPI.cancelReminders().catch(() => {}) } - }, [notes]) + }, []) } diff --git a/src/lib/editor/dslPlugin.ts b/src/lib/editor/dslPlugin.ts new file mode 100644 index 0000000..61c584a --- /dev/null +++ b/src/lib/editor/dslPlugin.ts @@ -0,0 +1,127 @@ +/** + * DSL Regex Parsing Engine + * + * Provides a flexible, highly-performant factory for creating CodeMirror ViewPlugins + * that match custom regex patterns in the editor and transform them into decorations + * or trigger actions. + * + * Performance: Scans only `view.visibleRanges` on each update, not the full document. + * This ensures O(visible lines) complexity instead of O(document length), keeping + * typing completely lag-free even with many complex rules. + */ + +import { ViewPlugin, ViewUpdate, EditorView, Decoration, WidgetType } from '@codemirror/view' +import { RangeSetBuilder } from '@codemirror/state' + +export interface DSLRule { + /** + * The regex to match. Must NOT have the `g` flag — the engine manages global matching + * per line internally. + */ + regex: RegExp + + /** + * CSS class name to apply as a mark decoration over the matched range. + * Used for purely visual highlighting (e.g., coloring a keyword). + * Either `className` or `widget` must be provided. + */ + className?: string + + /** + * Factory function to produce a WidgetType to insert BEFORE the match. + * Either `className` or `widget` must be provided. + */ + widget?: (match: RegExpExecArray) => WidgetType + + /** + * Optional side-effect action to trigger when a match is found (e.g., track state). + * This runs during the decoration-build phase; keep it pure and side-effect free + * (avoid state mutations here as it runs on every keystroke). + */ + onMatch?: (match: RegExpExecArray, from: number, to: number) => void +} + +interface BuiltMatch { + from: number + to: number + deco: Decoration +} + +function buildDecorations(view: EditorView, rules: DSLRule[]) { + const builder = new RangeSetBuilder() + const matches: BuiltMatch[] = [] + + // Only scan visible ranges for performance + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + + for (const rule of rules) { + // Create a new regex with the `g` flag from the rule's source + const gre = new RegExp( + rule.regex.source, + rule.regex.flags.includes('g') ? rule.regex.flags : rule.regex.flags + 'g' + ) + let match: RegExpExecArray | null + + while ((match = gre.exec(text)) !== null) { + const matchFrom = from + match.index + const matchTo = matchFrom + match[0].length + + if (rule.onMatch) { + rule.onMatch(match, matchFrom, matchTo) + } + + if (rule.widget) { + matches.push({ + from: matchFrom, + to: matchTo, + deco: Decoration.widget({ widget: rule.widget(match), side: -1 }), + }) + } else if (rule.className) { + matches.push({ + from: matchFrom, + to: matchTo, + deco: Decoration.mark({ class: rule.className }), + }) + } + } + } + } + + // Sort by `from` — required by RangeSetBuilder + matches.sort((a, b) => a.from - b.from || a.to - b.to) + + for (const m of matches) { + builder.add(m.from, m.to, m.deco) + } + + return builder.finish() +} + +/** + * Factory that creates a CodeMirror ViewPlugin from a set of DSL rules. + * + * @example + * const myPlugin = createRegexPlugin([ + * { regex: /\btodo\b/i, className: 'cm-todo-highlight' }, + * { regex: /!!(\w+)/, widget: (m) => new AlertWidget(m[1]) }, + * ]) + */ +export function createRegexPlugin(rules: DSLRule[]) { + return ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = buildDecorations(view, rules) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = buildDecorations(update.view, rules) + } + } + }, + { decorations: (v) => v.decorations } + ) +} diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index d0abc1a..a821a48 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -103,6 +103,16 @@ export function useEditorExtensions() { const line = view.state.doc.lineAt(pos) const lineText = line.text.trim() const lowerLine = lineText.toLowerCase() + + // /timer — open the timers panel and erase the command line + if (lowerLine === '/timer' || lowerLine.startsWith('/timer ')) { + const from = line.from + const to = Math.min(line.to + 1, view.state.doc.length) + view.dispatch({ changes: { from, to, insert: '' } }) + useAppStore.getState().setShowTimersView(true) + return true + } + if ( lowerLine.startsWith('/ai') || lowerLine.startsWith('/ctx') || diff --git a/src/lib/editor/slashCommands.ts b/src/lib/editor/slashCommands.ts index 6ff7480..76d6a4a 100644 --- a/src/lib/editor/slashCommands.ts +++ b/src/lib/editor/slashCommands.ts @@ -11,4 +11,5 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { label: '/context', apply: '/context ', info: 'AI completion with context', type: 'keyword' }, { label: '/task', apply: '/task ', info: 'Create a task', type: 'keyword' }, { label: '/check', apply: '/check ', info: 'Create a checkbox', type: 'keyword' }, + { label: '/timer', apply: '/timer ', info: 'Open timer panel', type: 'keyword' }, ] diff --git a/src/setupTests.ts b/src/setupTests.ts index 812a0ee..2985bad 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -72,6 +72,10 @@ if (typeof window !== 'undefined') { onPowerResume: vi.fn().mockReturnValue(() => {}), pauseShortcuts: vi.fn(), resumeShortcuts: vi.fn(), + scheduleReminders: vi.fn().mockResolvedValue(undefined), + cancelReminders: vi.fn().mockResolvedValue(undefined), + scheduleTimer: vi.fn().mockResolvedValue(undefined), + cancelTimer: vi.fn().mockResolvedValue(undefined), } as ElectronAPI } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index a93172e..3ae4142 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -6,6 +6,12 @@ export interface Note { mtime: number } +export interface Toast { + id: string + message: string + type: 'info' | 'success' | 'warning' | 'error' +} + interface AppState { notes: Note[] currentNoteIndex: number @@ -15,6 +21,8 @@ interface AppState { // UI state showGraphView: boolean showRemindersView: boolean + showTimersView: boolean + toasts: Toast[] isRenaming: boolean renameValue: string showNoteSearch: boolean @@ -33,6 +41,9 @@ interface AppState { setShowGraphView: (show: boolean | ((prev: boolean) => boolean)) => void setShowRemindersView: (show: boolean | ((prev: boolean) => boolean)) => void + setShowTimersView: (show: boolean | ((prev: boolean) => boolean)) => void + addToast: (toast: Omit) => void + removeToast: (id: string) => void setIsRenaming: (isRenaming: boolean) => void setRenameValue: (renameValue: string) => void setShowNoteSearch: (show: boolean) => void @@ -53,6 +64,8 @@ export const useAppStore = create((set) => ({ showGraphView: false, showRemindersView: false, + showTimersView: false, + toasts: [], isRenaming: false, renameValue: '', showNoteSearch: false, @@ -84,6 +97,18 @@ export const useAppStore = create((set) => ({ ? 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 }), diff --git a/src/store/useTimerStore.ts b/src/store/useTimerStore.ts new file mode 100644 index 0000000..022466b --- /dev/null +++ b/src/store/useTimerStore.ts @@ -0,0 +1,90 @@ +/** + * Timer Store + * + * Manages active countdown timers. Timer scheduling (sleeping until completion and + * triggering native OS notifications) is delegated to the Rust backend. + * The frontend store is responsible only for UI state (countdown display, completion status). + */ + +import { create } from 'zustand' + +export type TimerStatus = 'running' | 'paused' | 'completed' + +export interface Timer { + id: string + label: string + /** Total duration in milliseconds */ + durationMs: number + /** Remaining milliseconds (updated by the countdown loop) */ + remainingMs: number + /** Unix timestamp (ms) at which the timer will fire */ + endsAt: number + status: TimerStatus +} + +interface TimerState { + timers: Timer[] + addTimer: (label: string, durationMs: number) => string + removeTimer: (id: string) => void + tickTimer: (id: string) => void + completeTimer: (id: string) => void + pauseTimer: (id: string) => void + resumeTimer: (id: string) => void +} + +export const useTimerStore = create((set, get) => ({ + timers: [], + + addTimer: (label, durationMs) => { + const id = `timer-${Date.now()}-${Math.random().toString(36).slice(2)}` + const endsAt = Date.now() + durationMs + set((state) => ({ + timers: [ + ...state.timers, + { id, label, durationMs, remainingMs: durationMs, endsAt, status: 'running' }, + ], + })) + return id + }, + + removeTimer: (id) => { + set((state) => ({ timers: state.timers.filter((t) => t.id !== id) })) + }, + + tickTimer: (id) => { + set((state) => ({ + timers: state.timers.map((t) => { + if (t.id !== id || t.status !== 'running') return t + const remaining = Math.max(0, t.endsAt - Date.now()) + return { ...t, remainingMs: remaining } + }), + })) + }, + + completeTimer: (id) => { + set((state) => ({ + timers: state.timers.map((t) => + t.id === id ? { ...t, remainingMs: 0, status: 'completed' } : t + ), + })) + }, + + pauseTimer: (id) => { + set((state) => ({ + timers: state.timers.map((t) => + t.id === id && t.status === 'running' ? { ...t, status: 'paused' } : t + ), + })) + }, + + resumeTimer: (id) => { + const timer = get().timers.find((t) => t.id === id) + if (!timer || timer.status !== 'paused') return + const newEndsAt = Date.now() + timer.remainingMs + set((state) => ({ + timers: state.timers.map((t) => + t.id === id ? { ...t, endsAt: newEndsAt, status: 'running' } : t + ), + })) + }, +})) diff --git a/src/types.d.ts b/src/types.d.ts index 8871de8..8b563e7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -16,6 +16,10 @@ export interface ElectronAPI { readNote: (id: string) => Promise exportNote: (filename: string, content: string) => Promise setDialogOpen: (open: boolean) => Promise + scheduleReminders: (reminders: unknown[]) => Promise + cancelReminders: () => Promise + scheduleTimer: (id: string, durationMs: number, label: string) => Promise + cancelTimer: (id: string) => Promise quitApp: () => void openExternal: (url: string) => void From 94f4287dce66d0466572a2f8e6ff45a5a0c157b0 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 15:50:35 +0530 Subject: [PATCH 2/9] fix: add missing @emnapi/core and @emnapi/runtime entries to lockfile --- package-lock.json | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b79a140..5933482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,10 @@ "vite": "^8.0.12", "vitest": "^4.1.7", "zustand": "^5.0.14" + }, + "optionalDependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1" } }, "node_modules/@adobe/css-tools": { @@ -701,11 +705,35 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5725,7 +5753,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, From 3c2d4016b0aaf35fc0e7e07adcf9ad35bf36c337 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 15:57:27 +0530 Subject: [PATCH 3/9] fix: replace searchResults state with useMemo to fix set-state-in-effect lint error --- src/GraphView.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/GraphView.tsx b/src/GraphView.tsx index 2be9337..a9b1663 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -189,10 +189,14 @@ export default function GraphView({ // Search const [showSearch, setShowSearch] = useState(false) const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) const [searchIndex, setSearchIndex] = useState(0) const searchRef = useRef(null) + const searchResults = useMemo(() => { + if (!showSearch) return [] + return graphData.nodes.filter((n) => fuzzyMatch(n.name, searchQuery)) + }, [searchQuery, showSearch, graphData.nodes]) + const focusOnNode = useCallback((nodeId: string) => { const fg = fgRef.current if (!fg) return @@ -262,13 +266,6 @@ export default function GraphView({ return false } - useEffect(() => { - if (!showSearch) return - const filtered = graphData.nodes.filter((n) => fuzzyMatch(n.name, searchQuery)) - setSearchResults(filtered) - setSearchIndex(0) - }, [searchQuery, showSearch, graphData.nodes]) - const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { @@ -342,7 +339,10 @@ export default function GraphView({ setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value) + setSearchIndex(0) + }} onKeyDown={handleSearchKeyDown} placeholder="Search notes…" style={{ From 48035d5d62b2125e157df45214a25dcadb3ad580 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 16:01:12 +0530 Subject: [PATCH 4/9] fix: dereference &bool in Focused event match arm --- src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c4eb3a..8f1938c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -81,7 +81,7 @@ pub fn run() { let _ = w.hide(); } tauri::WindowEvent::Focused(focused) => { - if focused { + if *focused { #[cfg(not(target_os = "macos"))] pending.store(false, Ordering::SeqCst); } else if !is_dialog_open.load(Ordering::SeqCst) { From ca10fa42a1813979e92d0b31e502054489617d7a Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 16:04:40 +0530 Subject: [PATCH 5/9] fix: push circle mesh to z=1 for proper edge occlusion; reformat shortcuts without tables --- src/GraphView.tsx | 10 ++-------- src/hooks/useGlobalHotkey.ts | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/GraphView.tsx b/src/GraphView.tsx index a9b1663..8665c1f 100644 --- a/src/GraphView.tsx +++ b/src/GraphView.tsx @@ -214,15 +214,9 @@ export default function GraphView({ const group = new THREE.Group() const geometry = new THREE.CircleGeometry(12, 32) - const material = new THREE.MeshBasicMaterial({ - color, - side: THREE.DoubleSide, - transparent: true, - opacity: 0.99, - depthWrite: true, - }) + const material = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }) const circle = new THREE.Mesh(geometry, material) - circle.renderOrder = 1 + circle.position.z = 1 group.add(circle) const canvas = document.createElement('canvas') diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts index 285936c..6dff09d 100644 --- a/src/hooks/useGlobalHotkey.ts +++ b/src/hooks/useGlobalHotkey.ts @@ -113,20 +113,18 @@ export function useGlobalHotkey() { } else { const shortcutsContent = `# Shortcuts -| Shortcut | Action | -|----------|--------| -| \`Cmd+Shift+C\` | Toggle visibility (global, configurable) | -| \`Cmd+Shift+N\` | New note (global, configurable) | -| \`Cmd+Shift+S\` | Open settings | -| \`Cmd+N\` | New note | -| \`Cmd+T\` | Tasks / Reminders | -| \`Cmd+K\` | Main action menu | -| \`Cmd+P\` | Search notes | -| \`Cmd+G\` | Graph view | -| \`Cmd+F\` | Search in graph | -| \`Cmd+E\` | Export note | -| \`Cmd+/\` | Show this shortcuts reference | -| \`Esc\` | Close menus / modals | +- \`Cmd+Shift+C\` — Toggle visibility (global, configurable) +- \`Cmd+Shift+N\` — New note (global, configurable) +- \`Cmd+Shift+S\` — Open settings +- \`Cmd+N\` — New note +- \`Cmd+T\` — Tasks / Reminders +- \`Cmd+K\` — Main action menu +- \`Cmd+P\` — Search notes +- \`Cmd+G\` — Graph view +- \`Cmd+F\` — Search in graph +- \`Cmd+E\` — Export note +- \`Cmd+/\` — Show this shortcuts reference +- \`Esc\` — Close menus / modals ### Slash Commands Type \`/\` in the editor for inline suggestions: From 6c6877edde4d03fdd6f0ae48a1e29c29b48b099e Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 16:07:32 +0530 Subject: [PATCH 6/9] fix: add /timer to shortcuts reference --- src/hooks/useGlobalHotkey.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts index 6dff09d..a29d07d 100644 --- a/src/hooks/useGlobalHotkey.ts +++ b/src/hooks/useGlobalHotkey.ts @@ -131,6 +131,7 @@ Type \`/\` in the editor for inline suggestions: - \`/ai\` — AI prompt - \`/check\` — Checkbox - \`/task\` — Task with due date +- \`/timer\` — Countdown timer - \`/var\` — Local variable - \`/globvar\` — Global variable - \`/ctx\` — Context note From f553ebc0960c9c25392bb770895c69354865ac02 Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 24 Jun 2026 16:43:52 +0530 Subject: [PATCH 7/9] feat: restructure onboarding as linked note hub with topic navigation Welcome.md is now a concise central hub linking to separate onboarding/ topic notes (Editor, Commands, Graph, AI, Tasks, Customization) via the internal file link system. Each topic note has prev/next/back links for guided navigation. Version-marker rewrite logic preserves updates on version changes. Added explicit scroll-to-top on note switch for file links. Moved onboarding cleanup to dedicated Rust command. --- package.json | 4 + src-tauri/src/commands/fs.rs | 254 +++++++++++++++++++++++++++++------ src-tauri/src/lib.rs | 1 + src/api.ts | 2 +- src/components/Editor.tsx | 5 + src/types.d.ts | 1 + 6 files changed, 224 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 4abe37c..cf404ca 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,9 @@ "vite": "^8.0.12", "vitest": "^4.1.7", "zustand": "^5.0.14" + }, + "optionalDependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1" } } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 2cb201c..4ffafab 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -197,62 +197,233 @@ pub fn set_dialog_open(state: tauri::State<'_, crate::DialogState>, open: bool) state.is_open.store(open, Ordering::SeqCst); } +fn write_onboarding_file(base: &Path, rel_path: &str, content: &str, is_new_version: bool) { + let path = base.join(rel_path); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if !path.exists() || is_new_version { + let _ = fs::write(&path, content); + } +} + +#[tauri::command] +pub fn remove_onboarding_files() -> Result<(), String> { + if let Ok(base) = get_papercache_dir() { + let welcome = base.join("Welcome.md"); + let _ = fs::remove_file(&welcome); + + let onboarding_dir = base.join("onboarding"); + let _ = fs::remove_dir_all(&onboarding_dir); + + let commands_dir = base.join("commands"); + let _ = fs::remove_dir_all(&commands_dir); + + let marker = base.join(".onboarding_version"); + let _ = fs::remove_file(&marker); + } + Ok(()) +} + pub fn run_onboarding(app: &AppHandle) { let is_hyprland = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() || std::env::var("HYPRLAND_CMD").is_ok(); let mod_key = if is_hyprland { "Alt" } else { "Command/Ctrl" }; if let Ok(base) = get_papercache_dir() { - let welcome_path = base.join("Welcome.md"); - - let welcome_content = format!( - "# Welcome to PaperCache\n\nThis is your first note. Here's what you can do:\n\n\ - - **New note** — Press {} + N (or {} + Shift + N from anywhere)\n\ - - **Search notes** — Press {} + P\n\ - - **Graph view** — Press {} + G to see how your notes connect\n\ - - **Tasks & timers** — Press {} + T\n\ - - **Shortcuts** — Press {} + / for the full list\n\ - - **Slash commands** — Type `/` in the editor for AI, checkboxes, tasks, variables, and more\n\ - - **Settings** — Press {} + Shift + S\n\n\ - Start typing to edit this note, or create a new one!", - mod_key, mod_key, mod_key, mod_key, mod_key, mod_key, mod_key - ); - - if !welcome_path.exists() { - let _ = fs::write(&welcome_path, &welcome_content); - } else { - // Force update if the file contains the old generic shortcut text - if let Ok(content) = fs::read_to_string(&welcome_path) { - if content.contains("use shortcuts to create a new one!") || content.contains("Command/Ctrl + Shift + N") || content.contains("Alt + Shift + N") { - let _ = fs::write(&welcome_path, &welcome_content); - } - } + let version = app.package_info().version.to_string(); + let version_marker = base.join(".onboarding_version"); + let last_version = fs::read_to_string(&version_marker).ok(); + let is_new_version = last_version.as_deref() != Some(&version); + + if is_new_version { + let _ = fs::write(&version_marker, &version); } + let mk = mod_key; + + write_onboarding_file(&base, "Welcome.md", &format!( + "# Welcome to PaperCache\n\n\ + This is your first note. Start typing to edit it, or use **{0} + N** to create a new one. \ + PaperCache is a markdown-based knowledge manager with AI, graph visualization, tasks, timers, and more.\n\n\ + ## Quick Start\n\n\ + - **New note** — Press {0} + N (or {0} + Shift + N from anywhere)\n\ + - **Search notes** — Press {0} + P\n\ + - **Graph view** — Press {0} + G\n\ + - **Tasks & timers** — Press {0} + T\n\ + - **Settings** — Press {0} + Shift + S\n\n\ + ## Explore by Topic\n\n\ + - [Editor Features](/file onboarding/Editor.md) — Markdown, highlights, tags, pills, math, variables\n\ + - [Slash Commands](/file onboarding/Commands.md) — AI, tasks, checkboxes, timers, variables\n\ + - [Keyboard Shortcuts](/file Shortcuts.md) — Complete reference\n\ + - [Graph View](/file onboarding/Graph.md) — Knowledge graph visualization\n\ + - [AI Features](/file onboarding/AI.md) — Configuration and usage\n\ + - [Tasks & Timers](/file onboarding/Tasks.md) — Task management and countdowns\n\ + - [Customization & System](/file onboarding/Customization.md) — Themes, settings, system features\n\n\ + Press **{0} + Click** on any link above to jump to that note. \ + Or start typing to edit this one!", + mk + ), is_new_version); + + write_onboarding_file(&base, "onboarding/Editor.md", &format!( + "# Editor Features\n\n\ + PaperCache uses a full-featured markdown editor with these capabilities:\n\n\ + ## Markdown\n\n\ + Headings (H1-H6), bold, italic, strikethrough, lists, horizontal rules (`---`), \ + and fenced code blocks with language labels and one-click copy.\n\n\ + ## Highlights\n\n\ + Select text and press **{0} + H** to wrap it in `==text==` which renders as a visual highlight.\n\n\ + ## Tags\n\n\ + Type `!tagname` anywhere in your note. Tags appear as clickable pills \ + in the search view — right-click for bulk delete or export.\n\n\ + ## Color Pills\n\n\ + Hex colors like `#D97757` auto-render as a colored swatch. Click the circle to copy the hex code.\n\n\ + ## Date & Time Pills\n\n\ + `DD-MM-YYYY` and `HH:MM` formats auto-highlight for easy scanning.\n\n\ + ## Currency Pills\n\n\ + `$100`, `€50`, `£20`, `¥1000`, `₹500` auto-detect.\n\n\ + ## Reactive Math\n\n\ + Type an equation ending with `=` like `2+2=` and the result appears instantly. \ + Supports any arithmetic expression via `expr-eval`.\n\n\ + ## Variables\n\n\ + - `/var name = value` defines a note-scoped variable. Refer to it elsewhere and it auto-updates.\n\ + - `/globvar name = value` defines a cross-note global variable visible in all notes.\n\ + - Changing any variable re-evaluates all dependent math expressions.\n\n\ + ## Note Management\n\n\ + - **Auto-title** — The first `# Header` in a note becomes its display title\n\ + - **Rename** — Click the title bar to rename; changes the file ID\n\ + - **Internal links** — `[text](/file NoteTitle.md)` — {0} + Click to jump\n\ + - **External links** — `[text](url)` — {0} + Click to open in browser\n\ + - **Folders** — Use `/` in note names (e.g. `projects/my-note.md`) for nested folders with distinct colors\n\ + - **Delete** — {0} + Backspace with confirmation\n\n\ + ---\n\ + ↑ [Welcome](/file Welcome.md) → [Slash Commands](/file onboarding/Commands.md)", + mk + ), is_new_version); + + write_onboarding_file(&base, "onboarding/Commands.md", + "# Slash Commands\n\n\ + Type `/` in the editor to trigger autocomplete, then press Tab to accept. \ + Press Enter to execute.\n\n\ + ## Available Commands\n\n\ + - `/ai ` — Inline AI completion. Makes an API call and inserts the response.\n\ + - `/ctx ` — AI with full note context (up to 50,000 chars). Same as `/context`.\n\ + - `/context ` — Alias for `/ctx`.\n\ + - `/task