diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4602347 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +# Weekly dependency updates, one grouped PR per ecosystem so the review load is a +# single PR a week rather than one per package. Grouping every update into the +# same PR keeps the three lockfiles (package-lock.json, src-tauri/Cargo.lock, +# workflow SHAs) moving in lockstep with CI. +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + groups: + npm: + patterns: ["*"] + + - package-ecosystem: cargo + directory: "/src-tauri" + schedule: + interval: weekly + groups: + cargo: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5da4f30..18f0c61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,13 @@ jobs: node-version: 22 cache: npm - - uses: dtolnay/rust-toolchain@stable + # Must match rust-toolchain.toml; @stable would export RUSTUP_TOOLCHAIN and + # override the pin, building CI on a different compiler than local. + # components must be listed explicitly: the action installs a minimal + # profile, and the clippy/fmt steps below die without them. + - uses: dtolnay/rust-toolchain@1.91.1 + with: + components: clippy, rustfmt - uses: swatinem/rust-cache@v2 with: @@ -44,4 +50,10 @@ jobs: - run: cargo test --workspace --manifest-path src-tauri/Cargo.toml + # Lint gate: deny clippy warnings and enforce rustfmt so style and common + # bug patterns can't merge. Same src-tauri workspace as the cargo test above. + - run: cargo clippy --workspace --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings + + - run: cargo fmt --all --check --manifest-path src-tauri/Cargo.toml + - run: npm run tauri -- build --no-bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f296663..96c8786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,9 @@ jobs: node-version: 22 cache: npm - - uses: dtolnay/rust-toolchain@stable + # Must match rust-toolchain.toml; @stable would export RUSTUP_TOOLCHAIN and + # override the pin, building CI on a different compiler than local. + - uses: dtolnay/rust-toolchain@1.91.1 - uses: swatinem/rust-cache@v2 with: @@ -46,6 +48,14 @@ jobs: - run: npm ci + # A tag push ships to real users, so run the same quality gate as build.yml + # before spending time on the signed bundle: a red release must never draft. + - run: npm run check + + - run: npm test + + - run: cargo test --workspace --manifest-path src-tauri/Cargo.toml + # Release notes = the CHANGELOG section for this tag. tauri-action writes # this into both the GitHub release body and latest.json's "notes", so the # in-app updater shows real "What's new" text, not install boilerplate. diff --git a/.gitignore b/.gitignore index b23f159..d5e79b0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,6 @@ src-tauri/gen/ # SvelteKit generated files .svelte-kit/ -# Lock files (generated, noisy in diffs) -package-lock.json -src-tauri/Cargo.lock - # generated update manifest (scripts/make-update-manifest.sh) /latest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3888f..a87189c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,48 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Search results highlight what matched, in both the note title and the + excerpt. +- The command palette opens notes: press the palette shortcut and see your + five most recent notes, or type to search everything without leaving the + keyboard. +- Rename or delete a tag right in the sidebar: right-click it (or press + Shift+F10) for options, or double-click the tag to rename it in place. + +### Changed +- Deleting is calmer: moving notes to the Trash shows an Undo toast instead + of interrupting you, and permanent deletions ask in a proper in-app dialog + instead of a system popup. +- The quick capture panel closes when you click elsewhere (your draft is + kept), shows a brief "Saved" confirmation, and Cmd+Enter (Ctrl+Enter on + Windows) saves and opens the library. + +### Fixed +- Quitting can no longer lose your last moments of typing: every quit path + saves pending edits first, and a note whose save failed says "Not saved" + instead of pretending otherwise. +- Removing a #tag from a note's text now actually removes that tag from the + note. +- If the notes database is ever corrupted, the app sets it aside and starts + fresh instead of failing to launch, and tells you what happened. The + database is also backed up automatically before any update that changes + its format. +- Typing quickly in search can no longer show results for an older query. +- If another app owns the capture shortcut, the welcome screen now says so + instead of the shortcut silently doing nothing. + ## [0.7.0] - 2026-07-06 ### Added -- Windows and Linux support: InstantNotes now ships an installer for Windows - (x64) and an AppImage for Linux (x64) alongside the macOS build, all with - in-app updates. On Windows and Linux the capture panel is summoned with - `Ctrl+Shift+Space`, Settings and Quit live in the File menu, and shortcut - labels show `Ctrl+` combinations instead of mac glyphs. +- Windows support: InstantNotes now ships an installer for Windows (x64) + alongside the macOS build, both with in-app updates. On Windows the capture + panel is summoned with `Ctrl+Shift+Space`, Settings and Quit live in the + File menu, and shortcut labels show `Ctrl+` combinations instead of mac + glyphs. +- Linux (early preview): an AppImage (x64) is included, but Linux support is + still under development and may not work properly yet. The current focus is + getting InstantNotes fully functional on macOS and Windows first. - Settings is now a small wiki: a landing grid of category cards opening focused sub-pages with a breadcrumb back, including the new Contexting page. - Contexting: a template that shapes what "Copy note as context" hands to diff --git a/README.md b/README.md index bcdf0ce..0458642 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ # InstantNotes -Instant notes for macOS, Windows, and Linux. Capture, organize, and search your thoughts. - -InstantNotes is a desktop notes app built around fast capture and a focused library. Save a thought from anywhere with a global shortcut, then organize and retrieve it without being forced into a folder system. +Instantly externalize your writing. Capture and locate your thoughts. ## Features -- **Instant capture**: a lightweight capture panel summoned from the system tray or via a global hotkey (`Option+Space` on macOS, `Ctrl+Shift+Space` on Windows and Linux), with drafts preserved if dismissed -- **Focused library**: a two-section sidebar (All Notes and Workspaces) over a note list and editor, with pinned notes floated to the top and a status filter for archived and trashed notes -- **Workspaces**: named collections that group related notes; a note can live in many workspaces, and deleting a workspace never deletes its notes -- **Full-text search**: SQLite FTS5 search over titles and bodies with ranked results, using plain-language queries with no search syntax to learn -- **Command palette**: a `Cmd+K` (`Ctrl+K`) palette for running actions and switching themes, with arrow-key navigation and recents; search reaches into sub-menus (typing a theme name jumps straight to it), and the Themes sub-menu applies each theme live so you can preview as you arrow through -- **Tags, not folders**: lightweight labels, including tags extracted from `#inline` text -- **Local and private**: all data stored locally in SQLite; note content never appears in logs or diagnostics +- **Quick Capture:** Global hotkey (`Opt` / `Ctrl+Shift+Space`) with drafts. +- **Command Palette:** `Cmd/Ctrl+P` for actions, search, and themes. +- **Flexible Organization:** Group notes with Workspaces and `#inline` tags instead of strict folders. +- **100% Local & Private:** Everything lives in a local SQLite database. Zero telemetry. ## Installation - -Download the latest build for your platform from the [releases page](../../releases). The builds are unsigned, so each OS asks for a one-time confirmation on first launch; the in-app updater applies later versions without any of it. +> The builds are unsigned, You will likely be prompted on first launch. +Download the latest build for your platform from the [releases page](../../releases). ### macOS (Apple Silicon) -Download the `.dmg`, open it, and drag InstantNotes to Applications. The app is not notarized, so macOS blocks the first launch with an "Apple could not verify" message. Clear the quarantine flag and it opens normally from then on: +Download the `.dmg`, open it, and drag InstantNotes to Applications. +The app is not notarized, macOS blocks the first launch with an "Apple could not verify" message. +Clear the quarantine flag and it opens normally from then on: ```sh xattr -d com.apple.quarantine /Applications/InstantNotes.app @@ -30,40 +27,27 @@ Alternatively, after the blocked first launch, open System Settings, go to Priva ### Windows (x64) -Download and run the `-setup.exe` installer. SmartScreen flags the unsigned build: click "More info", then "Run anyway". - -### Linux (x64) - -Download the `.AppImage`, make it executable, and run it: - -```sh -chmod +x InstantNotes_*.AppImage -./InstantNotes_*.AppImage -``` - -The app lives in the system tray; on desktops without tray support (such as stock GNOME, which needs the AppIndicator extension), use the in-window File menu to quit and the library window to work. - -To build from source instead, see [Development](#development). +Download release then run installer. +You will get a SmartScreen flags due to the unsigned build: + 1. To proceed --> click "More info", then "Run anyway". ## Development +To build from source instead ### Prerequisites -- macOS, Windows, or Linux +- macOS, Windows - [Rust](https://rustup.rs/) via rustup (the version is pinned by `rust-toolchain.toml`) - Node.js 22+ -- Linux only: the [Tauri system dependencies](https://v2.tauri.app/start/prerequisites/#linux) (webkit2gtk 4.1 and friends) -- Windows only: the Visual Studio Build Tools with the C++ workload +- Windows: the Visual Studio Build Tools with the C++ workload ### Run the app ```sh npm install -npm run tauri dev +npm run tauri:dev ``` -This builds the Rust core, starts the Vite dev server, and launches the app. Frontend changes hot-reload instantly; Rust changes trigger an incremental rebuild and app restart. - ### Test ```sh @@ -82,7 +66,10 @@ Produces the platform's bundles under `src-tauri/target/release/bundle/`: an `.a ### Release (with self-update) -The app checks GitHub Releases for updates on launch and every 6 hours, via `latest.json` attached to the latest release. Each release's "What's new" text - shown in the in-app update panel and on the GitHub release - comes from the matching `CHANGELOG.md` section. Releases are built, signed, and published by CI: +The app checks GitHub Releases for updates on launch and every 6 hours, via `latest.json` attached to the latest release. + +Release's are shown on update panel +Releases are built, signed, and published by CI: ```sh # 1. Add a "## [X.Y.Z]" section to CHANGELOG.md describing the release. @@ -97,33 +84,8 @@ git tag vX.Y.Z && git push origin vX.Y.Z `npm run bump` updates package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and src-tauri/Cargo.lock together (the `instantnotes-core` crate versions independently). Publishing the smoke-tested draft is the deliberate ship gate; the draft stays invisible to the in-app updater until then. -CI signs the updater artifact with the minisign key stored in the repo secrets `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and the app verifies downloads against the matching public key in `tauri.conf.json`. If the secret is ever lost, generate a new keypair with `npm run tauri signer generate`, update both the secret and the pubkey, and ship one manual release so installs can cross over. - -For a fully local release without CI (macOS-only fallback: `make-update-manifest.sh` writes just the `darwin-aarch64` entry), build with `TAURI_SIGNING_PRIVATE_KEY` set, run `./scripts/make-update-manifest.sh`, and upload the dmg, `InstantNotes.app.tar.gz`, and `latest.json` with `gh release create`. Release downloads must be publicly reachable for the in-app check to work. - -### Project structure - -``` -src/ Svelte 5 frontend (library window, capture panel) -src-tauri/ Rust core and Tauri 2 shell -openspec/ Product specification and project conventions -static/ Static assets -``` - -### Tech stack - -| Layer | Technology | -|---|---| -| Desktop shell | Tauri 2 | -| Core (persistence, search, commands) | Rust | -| Storage and search | SQLite + FTS5 | -| UI | Svelte 5 + TypeScript | -| Editor | CodeMirror 6 | -| Testing | cargo test, Vitest, svelte-check | - -### Architecture +CI signs the updater artifact with the minisign key stored in the repo secrets `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and the app verifies downloads against the matching public key in `tauri.conf.json`. -A local Rust core sits behind a thin desktop shell. Rust owns business rules and persistence; TypeScript owns view state and typed IPC calls. The library window, capture panel, and future settings window communicate through typed commands and change events. UI code calls the API client rather than invoking Tauri commands directly. Product requirements and conventions live in [`openspec/`](openspec/): diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..dda6230 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,124 @@ +# API contract + +The IPC surface between the Svelte webview and the Rust core. Every command +is registered in `src-tauri/src/lib.rs` and wrapped by a typed function in +`src/lib/api/client.ts`; the request and response types live in +`src/lib/api/types.ts`. Those files are the source of truth for exact +signatures. This document describes the shape of the boundary and enumerates +the stable error codes. + +## 1. Overview + +The core owns all business rules and persistence. UI code never invokes Tauri +commands directly: it calls the `client.ts` wrappers, which invoke the command +and translate failures into an `ApiError`. Data mutations happen only through +these commands; the frontend re-queries on `notes:changed`, `tags:changed`, +and `workspaces:changed` events rather than mutating local state optimistically. + +## 2. Invocation + +Each wrapper calls `invoke(name, args)` and returns a typed result. Arguments +are JSON objects; the one exception is `save_attachment`, which sends raw image +bytes as the request body with the file extension in an `x-attachment-ext` +header (see section 8). + +## 3. Error handling + +Commands return a `Result`. On failure the core produces an `AppError`, which +serializes to a plain object and is rethrown by the client as an `ApiError` +carrying the same `code`. `src/lib/errors.ts` maps each code to user-facing +copy; unknown codes fall back to a generic message. + +### 3.6 Error object shape + +A failed command rejects with: + +``` +{ code: string, message: string } +``` + +`code` is one of the stable identifiers in section 11. `message` is a +developer-facing description and is never shown to users verbatim. + +## 4. Notes + +| Command | Purpose | +| --- | --- | +| `create_note` | Create a note; title is derived from the body (see DATA_MODEL.md section 6). | +| `get_note` | Fetch one note by id. | +| `update_note` | Patch title/body/flags; an empty patch is a no-op. | +| `soft_delete_note` | Move a note to trash (`is_deleted = 1`). | +| `restore_note` | Restore a trashed note. | +| `permanently_delete_note` | Destroy a note and its rows for good. | +| `list_notes` | List notes for a status/space/tag filter. | +| `search_notes` | Full-text search over title and body (section 7 of DATA_MODEL.md). | + +## 5. Tags + +| Command | Purpose | +| --- | --- | +| `list_tags` | All tags with usage counts. | +| `get_or_create_tag` | Resolve a normalized tag name to a tag, creating it if new. | +| `update_tag` | Rename or recolor a tag. | +| `delete_tag` | Remove a tag and its note associations. | +| `add_tag_to_note` / `remove_tag_from_note` | Attach or detach a tag. | +| `tags_for_note` | Tags on one note. | + +## 6. Workspaces + +Workspaces are named note collections (many-to-many, like tags). The UI labels +them "Spaces"; the command and storage names keep `workspace`. + +| Command | Purpose | +| --- | --- | +| `list_workspaces` | All workspaces with note counts. | +| `get_or_create_workspace` | Resolve a name to a workspace, creating it if new. | +| `rename_workspace` | Rename a workspace (duplicate names rejected as `CONFLICT`). | +| `delete_workspace` | Delete a workspace; member notes are untouched. | +| `list_workspace_tags` | Tags used by notes in one workspace. | +| `add_note_to_workspace` / `remove_note_from_workspace` | Manage membership. | +| `workspaces_for_note` | Workspaces a note belongs to. | + +## 7. Settings + +A simple key/value store for UI preferences. `get_setting`, `set_setting`, +`delete_setting`. Values are JSON-encoded; reads are best-effort and fall back +to defaults when a key is absent or malformed. + +## 8. Attachments + +| Command | Purpose | +| --- | --- | +| `save_attachment` | Store one pasted/dropped image; raw bytes in the body, extension in `x-attachment-ext`. Returns the generated filename. | +| `get_attachments_dir` | Absolute path of the attachments directory. | + +Images live as files under `/attachments` and notes reference them by +relative `attachments/` markdown paths. The webview reads them back over +the asset protocol, scoped to that directory in `tauri.conf.json`. + +## 9. Windows and shell + +`hide_capture`, `open_library`, `set_window_vibrancy`, `set_window_theme`, +`export_theme_file`, `import_theme_file`, `export_note_file`, `open_url`, +`quit_app`. These drive native windows, theme file I/O, and external links; they +carry no note data beyond what the user explicitly exports. + +## 10. Capture + +`capture_input_ready` and `get_capture_latency` instrument the capture window's +reveal-to-ready latency, surfaced in the About panel as a product number. + +## 11. Error codes + +| Code | Meaning | +| --- | --- | +| `NOT_FOUND` | The requested record does not exist. | +| `VALIDATION_ERROR` | Input failed a rule (empty name, bad type). | +| `CONFLICT` | A uniqueness constraint was violated (duplicate name). | +| `STORAGE_ERROR` | A persistence failure, including a corrupt database file. | +| `MIGRATION_ERROR` | The schema could not be upgraded. | + +These are the only codes callers may branch on; they are asserted in +`src-tauri/core/src/error.rs`. Database corruption is reported as +`STORAGE_ERROR` externally, while the core keeps it distinct internally so it +can set aside a damaged file and start fresh. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..42c7d2c --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,84 @@ +# Data model + +The local storage schema and the pure rules that shape it. The schema is +defined by the ordered migrations in `src-tauri/core/src/store.rs` +(`MIGRATIONS`); the naming and title rules live in +`src-tauri/core/src/domain.rs`. SQLite with FTS5 backs everything; no data +leaves the machine. + +## 1. Overview + +Notes are the canonical user data. Tags and workspaces are lightweight +labels/collections attached many-to-many. Deleting a tag or workspace never +deletes notes. Timestamps are ISO-8601 strings. + +## 2. Naming and normalization + +### 2.1 Workspace names + +Trim and collapse repeated whitespace, preserving case (workspaces are display +names). An empty result is rejected. Uniqueness is case-insensitive +(`COLLATE NOCASE`). + +### 2.2 Tag names + +`normalize_tag_name` produces the canonical form: trim, strip leading `#`, +lowercase, and collapse repeated whitespace. An empty result yields no tag. +Inline `#tag` tokens are extracted from body text when a `#` sits at the start +or after whitespace and is followed by alphanumeric / `-` / `_` characters; +extracted tags are normalized and deduped in order of first appearance. + +## 3. Notes + +`notes` table (primary key `seq`, a stable integer alias used as the FTS +content rowid; `id` is the public UUID): + +| Column | Notes | +| --- | --- | +| `seq` | Integer primary key; FTS `content_rowid`. | +| `id` | Public UUID, unique. | +| `title`, `title_is_auto` | Title and whether it was auto-derived (section 6). | +| `body` | Markdown body. | +| `created_at`, `updated_at`, `last_opened_at` | Lifecycle timestamps. | +| `is_pinned`, `is_archived`, `is_deleted`, `deleted_at` | Status flags. | + +Indexes cover `updated_at`, `created_at`, and the status-flag triple. + +## 4. Tags + +`tags` (id, unique `name`, optional `color`, timestamps) and the join table +`note_tags` (`note_id`, `tag_id`, `created_at`, `source` = `manual` or the +inline-extraction source), keyed on the pair, cascading on note or tag delete. + +## 5. Workspaces + +`workspaces` (id, unique case-insensitive `name`, timestamps) and the join +table `note_workspaces` (`note_id`, `workspace_id`, `created_at`), keyed on the +pair, cascading on note or workspace delete. The UI calls these "Spaces". + +## 6. Title derivation + +`derive_title` takes the first non-empty line of the body, strips leading +markdown markers (`#`, `-`, `*`, `>`) and inline `#` tag prefixes, collapses +whitespace, and truncates to 80 characters on a char boundary. An empty body +yields `Untitled`. A note keeps `title_is_auto = 1` until the user edits the +title directly. + +## 7. Full-text search + +`notes_fts` is an FTS5 external-content table over `title` and `body` +(`content='notes'`, `content_rowid='seq'`, `porter unicode61` tokenizer). +Triggers keep it in sync on insert, delete, and title/body update. Search +accepts plain user input without exposing FTS syntax errors. + +## 8. Settings + +`settings` is a `key`/`value`/`updated_at` table holding JSON-encoded UI +preferences. Reads are best-effort: a missing or malformed value falls back to +the code default. + +## 9. Migrations + +`MIGRATIONS` is an ordered list of SQL scripts; `user_version` records how many +have run, so a database upgrades forward exactly once per version. The list is +public so tests can build fixtures at a historical schema version. diff --git a/docs/superpowers/specs/2026-07-10-spaces-design.md b/docs/superpowers/specs/2026-07-10-spaces-design.md new file mode 100644 index 0000000..f0b31d0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-spaces-design.md @@ -0,0 +1,102 @@ +# Spaces Design (Workspace Management Rework) + +**Date:** 2026-07-10 +**Branch target:** `feat/spaces` +**Status:** Approved - pending implementation plan +**Interactive demo:** https://claude.ai/code/artifact/0b62b62a-1682-440f-a16d-e464b9197686 (Direction 2.1) + +--- + +## Concept + +A **space** is an intentional place you go to work on one thing. A **tag** is emergent: it is born at capture time ("fix xyz bug #bug", hotkey, done) and closes the open loop with zero ceremony. Both stay one-click destinations in the sidebar. + +The razor: if you would go there to add something, it is a space. If you would look for it across places, it is a tag. + +Consequences of the definition: + +- Empty spaces are legitimate (intent before content) and always stay visible. Tags with zero uses stay hidden, as today. +- Deleting a space never touches notes, so deletion is routine and low-stakes: immediate, with Undo, no confirm dialog. +- Rename is a routine verb and must be reachable. +- At the graph and local-AI stages, a space is the natural context boundary (subgraph, retrieval scope). Nothing in this design needs undoing then. + +**Vocabulary:** the UI says "Space / Spaces" everywhere. The backend, schema, and command names keep the `workspace` naming (`note_workspaces`, `rename_workspace`, and so on). Renaming storage internals is churn with zero user value. + +--- + +## Summary + +1. Space rows adopt the exact Tags interaction contract: no resting chrome, context menu (Rename / Delete), double-click inline rename. The hover-reveal delete button is removed. +2. Delete becomes immediate + undo toast, replacing the confirm dialog. Undo restores the space and its memberships. +3. Inside a space, the note list header gains tag chips scoped to that space's own tags. Tapping one filters within the space. This is the new capability: one "To do" space sliced by #school / #car / #errand instead of parallel todo spaces. +4. UI wording sweep from "workspace" to "space". + +--- + +## Interaction design + +| Action | Behavior | +|---|---| +| Switch | Click a row. Click the active row again to return to All Notes (unchanged). | +| Create | The quiet "New space..." input at the bottom of the section (unchanged position). Enter commits; an existing name switches to that space (current `getOrCreateWorkspace` behavior). | +| Rename | Double-click the row, or context menu > Rename Space. Enter commits, Escape cancels, blur cancels. Empty or duplicate names keep edit mode and show the inline error + shake, exactly like `TagRow`. Backed by the already-tested `rename_workspace` (duplicate guard exists). | +| Delete | Context menu > Delete Space (danger item). Immediate. Toast: `Deleted "Movies" - notes are kept` with an Undo action. No confirm dialog. | +| Context menu | Right-click or Shift+F10, via the existing `ContextMenu.svelte`. Items: Rename Space, Delete Space. | + +### Scoped tag chips + +- Shown only when a space is active, and only for tags present on that space's notes. No chips in All Notes (the rail already covers global tag access) and none when the space has no tagged notes. +- Tap toggles the filter within the space; the note count in the header reflects the filtered list. +- Entering or leaving a space, or switching spaces, clears the scoped tag. +- Rail tags keep today's semantics: global, one click, exclusive with spaces. + +--- + +## Store changes (`library.svelte.ts`) + +| Change | Detail | +|---|---| +| New state `scopedTagId` | Composes with `activeWorkspaceId`: `#filter()` sets both `workspaceId` and `tagIds` when a space and a scoped tag are active. `selectWorkspace()` resets it. Global `setTagFilter()` keeps its current semantics (clears the workspace). | +| `removeWorkspace()` | Snapshot the space's member note ids, delete, then `toasts.show` with an Undo action. Undo recreates the space by name and re-adds memberships, mirroring `#undoSoftDelete` (failures land in a plain toast, never throw into the toast handler). | +| Rename path | New `renameWorkspace()` following the Sidebar `renameTag` shape: call the client, refresh, map `ApiError` to an inline `{ ok, message }` result. | +| Active-space rename | `workspaces:changed` already refreshes the list; the implementation must also refresh `selectedWorkspaces` when a note is open so editor membership chips pick up the new name. | +| Delete active space | Lands in All Notes (`refreshWorkspaces` already handles the space disappearing; keep the explicit reset in `removeWorkspace` too). | + +--- + +## Backend + +- `rename_workspace` exists and is unit-tested (Rust + TS client, duplicate-name guard). This design finally wires it to the UI. +- **Undo fidelity item (verify during implementation):** the membership snapshot must include archived and trashed member notes. If `list_notes({ workspaceId })` excludes them by default, extend `delete_workspace` to return the member note ids (Rust + TS client + tests) instead of snapshotting client-side. +- Undo recreates the space via `get_or_create_workspace`, so it gets a new id. Acceptable: nothing persists space ids across sessions, and the active selection was already reset. + +--- + +## Files changed + +| File | Change | +|---|---| +| `src/lib/components/SpaceRow.svelte` | **New.** Sibling of `TagRow.svelte` with the same contract (select, dblclick rename, inline error, context menu hook). A light sibling, not a premature abstraction over two consumers. | +| `src/lib/components/Sidebar.svelte` | Space rows via `SpaceRow`; remove hover-x markup and CSS; remove the confirm-dialog delete path; menu state for spaces; wording. | +| `src/lib/components/NoteList.svelte` | Scoped tag chip row below the toolbar when a space is active. The status-filter pills already hide there (`NoteList.svelte:46`), so the chips take that slot rather than adding a new band. Also the "No notes in this workspace yet" empty-state wording. | +| `src/lib/stores/library.svelte.ts` | `scopedTagId`, `renameWorkspace()`, undo-delete `removeWorkspace()`. | +| `src/lib/components/NoteEditor.svelte` | Wording: "Add to space...", membership chip labels. | +| `src-tauri/src/lib.rs` | Only if the undo fidelity item requires `delete_workspace` to return member ids. | +| Command palette / misc strings | Sweep any "workspace" UI strings found in `commands.ts`, `palette-sections.ts`, hints, tooltips. | + +--- + +## Testing + +- Store tests: undo restores memberships (including archived/trashed members), delete of the active space resets to All Notes, scoped tag composes with the space filter and clears on space switch. +- Component behavior: rename commit / cancel / duplicate error parity with `TagRow`; menu opens via right-click and Shift+F10. +- Rust tests only if `delete_workspace` changes shape. +- Existing `rename_workspace` tests already cover the duplicate guard. + +--- + +## Out of scope + +- Reordering spaces, bulk operations, a manage modal, an Unfiled view (revisit at the graph stage). +- Any schema or backend renaming from `workspace` to `space`. +- Per-note membership UI in the editor (unchanged apart from wording). diff --git a/openspec/changes/chore-project-scaffold/proposal.md b/openspec/changes/archive/chore-project-scaffold/proposal.md similarity index 100% rename from openspec/changes/chore-project-scaffold/proposal.md rename to openspec/changes/archive/chore-project-scaffold/proposal.md diff --git a/openspec/changes/chore-project-scaffold/tasks.md b/openspec/changes/archive/chore-project-scaffold/tasks.md similarity index 100% rename from openspec/changes/chore-project-scaffold/tasks.md rename to openspec/changes/archive/chore-project-scaffold/tasks.md diff --git a/openspec/changes/feat-editor-toolbar/proposal.md b/openspec/changes/archive/feat-editor-toolbar/proposal.md similarity index 100% rename from openspec/changes/feat-editor-toolbar/proposal.md rename to openspec/changes/archive/feat-editor-toolbar/proposal.md diff --git a/openspec/changes/feat-editor-toolbar/tasks.md b/openspec/changes/archive/feat-editor-toolbar/tasks.md similarity index 100% rename from openspec/changes/feat-editor-toolbar/tasks.md rename to openspec/changes/archive/feat-editor-toolbar/tasks.md diff --git a/openspec/changes/feat-list-tab-indent/proposal.md b/openspec/changes/archive/feat-list-tab-indent/proposal.md similarity index 100% rename from openspec/changes/feat-list-tab-indent/proposal.md rename to openspec/changes/archive/feat-list-tab-indent/proposal.md diff --git a/openspec/changes/feat-list-tab-indent/tasks.md b/openspec/changes/archive/feat-list-tab-indent/tasks.md similarity index 100% rename from openspec/changes/feat-list-tab-indent/tasks.md rename to openspec/changes/archive/feat-list-tab-indent/tasks.md diff --git a/openspec/changes/feat-local-notes-engine/proposal.md b/openspec/changes/archive/feat-local-notes-engine/proposal.md similarity index 100% rename from openspec/changes/feat-local-notes-engine/proposal.md rename to openspec/changes/archive/feat-local-notes-engine/proposal.md diff --git a/openspec/changes/feat-local-notes-engine/tasks.md b/openspec/changes/archive/feat-local-notes-engine/tasks.md similarity index 100% rename from openspec/changes/feat-local-notes-engine/tasks.md rename to openspec/changes/archive/feat-local-notes-engine/tasks.md diff --git a/openspec/changes/feat-macos-capture-shell/proposal.md b/openspec/changes/archive/feat-macos-capture-shell/proposal.md similarity index 100% rename from openspec/changes/feat-macos-capture-shell/proposal.md rename to openspec/changes/archive/feat-macos-capture-shell/proposal.md diff --git a/openspec/changes/feat-macos-capture-shell/tasks.md b/openspec/changes/archive/feat-macos-capture-shell/tasks.md similarity index 100% rename from openspec/changes/feat-macos-capture-shell/tasks.md rename to openspec/changes/archive/feat-macos-capture-shell/tasks.md diff --git a/openspec/changes/feat-notes-library-ui/proposal.md b/openspec/changes/archive/feat-notes-library-ui/proposal.md similarity index 100% rename from openspec/changes/feat-notes-library-ui/proposal.md rename to openspec/changes/archive/feat-notes-library-ui/proposal.md diff --git a/openspec/changes/feat-notes-library-ui/tasks.md b/openspec/changes/archive/feat-notes-library-ui/tasks.md similarity index 100% rename from openspec/changes/feat-notes-library-ui/tasks.md rename to openspec/changes/archive/feat-notes-library-ui/tasks.md diff --git a/openspec/changes/feat-settings-contexting/proposal.md b/openspec/changes/archive/feat-settings-contexting/proposal.md similarity index 100% rename from openspec/changes/feat-settings-contexting/proposal.md rename to openspec/changes/archive/feat-settings-contexting/proposal.md diff --git a/openspec/changes/feat-settings-contexting/tasks.md b/openspec/changes/archive/feat-settings-contexting/tasks.md similarity index 100% rename from openspec/changes/feat-settings-contexting/tasks.md rename to openspec/changes/archive/feat-settings-contexting/tasks.md diff --git a/openspec/changes/feat-titlebar-theme-sync/proposal.md b/openspec/changes/archive/feat-titlebar-theme-sync/proposal.md similarity index 100% rename from openspec/changes/feat-titlebar-theme-sync/proposal.md rename to openspec/changes/archive/feat-titlebar-theme-sync/proposal.md diff --git a/openspec/changes/feat-titlebar-theme-sync/tasks.md b/openspec/changes/archive/feat-titlebar-theme-sync/tasks.md similarity index 100% rename from openspec/changes/feat-titlebar-theme-sync/tasks.md rename to openspec/changes/archive/feat-titlebar-theme-sync/tasks.md diff --git a/openspec/changes/feat-updater-overhaul/proposal.md b/openspec/changes/archive/feat-updater-overhaul/proposal.md similarity index 100% rename from openspec/changes/feat-updater-overhaul/proposal.md rename to openspec/changes/archive/feat-updater-overhaul/proposal.md diff --git a/openspec/changes/feat-updater-overhaul/tasks.md b/openspec/changes/archive/feat-updater-overhaul/tasks.md similarity index 100% rename from openspec/changes/feat-updater-overhaul/tasks.md rename to openspec/changes/archive/feat-updater-overhaul/tasks.md diff --git a/openspec/changes/feat-workspaces/proposal.md b/openspec/changes/archive/feat-workspaces/proposal.md similarity index 100% rename from openspec/changes/feat-workspaces/proposal.md rename to openspec/changes/archive/feat-workspaces/proposal.md diff --git a/openspec/changes/feat-workspaces/tasks.md b/openspec/changes/archive/feat-workspaces/tasks.md similarity index 100% rename from openspec/changes/feat-workspaces/tasks.md rename to openspec/changes/archive/feat-workspaces/tasks.md diff --git a/openspec/changes/fix-palette-long-title/proposal.md b/openspec/changes/archive/fix-palette-long-title/proposal.md similarity index 100% rename from openspec/changes/fix-palette-long-title/proposal.md rename to openspec/changes/archive/fix-palette-long-title/proposal.md diff --git a/openspec/changes/fix-palette-long-title/tasks.md b/openspec/changes/archive/fix-palette-long-title/tasks.md similarity index 100% rename from openspec/changes/fix-palette-long-title/tasks.md rename to openspec/changes/archive/fix-palette-long-title/tasks.md diff --git a/openspec/changes/tauri-conformance-and-library-refactor/proposal.md b/openspec/changes/archive/tauri-conformance-and-library-refactor/proposal.md similarity index 100% rename from openspec/changes/tauri-conformance-and-library-refactor/proposal.md rename to openspec/changes/archive/tauri-conformance-and-library-refactor/proposal.md diff --git a/openspec/changes/tauri-conformance-and-library-refactor/tasks.md b/openspec/changes/archive/tauri-conformance-and-library-refactor/tasks.md similarity index 100% rename from openspec/changes/tauri-conformance-and-library-refactor/tasks.md rename to openspec/changes/archive/tauri-conformance-and-library-refactor/tasks.md diff --git a/openspec/changes/themes-and-command-palette/proposal.md b/openspec/changes/archive/themes-and-command-palette/proposal.md similarity index 100% rename from openspec/changes/themes-and-command-palette/proposal.md rename to openspec/changes/archive/themes-and-command-palette/proposal.md diff --git a/openspec/changes/themes-and-command-palette/tasks.md b/openspec/changes/archive/themes-and-command-palette/tasks.md similarity index 100% rename from openspec/changes/themes-and-command-palette/tasks.md rename to openspec/changes/archive/themes-and-command-palette/tasks.md diff --git a/openspec/project.md b/openspec/project.md index c984a25..5d49812 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -3,6 +3,17 @@ ## Purpose InstantNotes is a macOS desktop notes app built around fast capture and a focused library. The app should let a user save a thought from anywhere, then organize and retrieve it without forcing a folder system. +## Product Thesis +InstantNotes exists to close open loops (the Zeigarnik effect: unfinished intentions stay resident in the head until they are parked in a trusted system). Every stage of the roadmap serves one of three promises: + +1. **Capture is discharge.** Writing the thought down must cost less than carrying it. The capture path stays under the reflex threshold; a feature that adds a decision at capture time is rejected on those grounds. +2. **Trust is release.** The mind only lets go if retrieval is guaranteed. Data solidity work (save queues, undo fidelity, race protection) is this promise stated in engineering. +3. **Resurfacing is closure.** A parked loop must come back at the right moment, concrete enough to act on. Notes already carry the data for this (`updatedAt`, `lastOpenedAt`, spaces, tags); the graph and local-AI stages build on it. + +The target is **powerful, not simple**: simplicity through frictionless apparent complexity. The app helps, never hinders, never confuses, never overcomplicates. A growing library must not read as clutter; it should feel satisfying and be useful by default. The success metric is inverted from engagement: the system works when the user stops re-checking it. + +Anti-goal: InstantNotes is not a task manager. Dates, checkboxes, and notifications belong to other tools; our thirds of the loop are trusted parking, clarifying, and resurfacing. New features are tested against one question: does this close loops or create them? + ## Tech Stack - Tauri 2 desktop shell - Rust core for persistence, search, and command handling @@ -25,9 +36,15 @@ Core behavior is covered with Rust tests against SQLite. Frontend utilities are ### Git Workflow Each public branch should contain one clear product step. Commit subjects stay short and concrete; bodies explain what changed in one or two natural sentences. +### Change Proposals +Active OpenSpec change proposals live in `openspec/changes/`. Once a change has shipped, its folder moves to `openspec/changes/archive/` so the active list only shows work in flight. The canonical IPC and storage contracts are documented in `docs/API.md` and `docs/DATA_MODEL.md`. + ## Domain Context Notes are the canonical user data. Tags are lightweight labels, including tags extracted from `#inline` text. Workspaces are named collections that group related notes; a note can belong to more than one, and deleting a workspace never deletes its notes. Search must support plain user input without exposing FTS syntax errors. +### Glossary: Space = Workspace +The product term is **Space** (sidebar, copy, component names). The storage tables, IPC command names, and Rust core keep the original **workspace** name. This is deliberate: renaming storage internals is churn with no user value. The one place the two vocabularies meet is `src/lib/api/client.ts`, which documents the boundary; UI and store code say "space", everything from the command strings down says "workspace". + ## Important Constraints - Notes are stored locally. - Note content must not appear in logs. diff --git a/package-lock.json b/package-lock.json index 555bb19..baeb15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { "name": "instantnotes", - "version": "0.5.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instantnotes", - "version": "0.5.2", + "version": "0.8.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6", "@codemirror/view": "^6", "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.7.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-process": "^2.3.1", @@ -25,12 +27,131 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", "@types/node": "^25.9.3", + "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", "vite": "^6.0.3", "vitest": "^3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "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/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "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" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, "node_modules/@codemirror/autocomplete": { @@ -57,6 +178,30 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, "node_modules/@codemirror/lang-css": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", @@ -70,6 +215,19 @@ "@lezer/css": "^1.1.7" } }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, "node_modules/@codemirror/lang-html": { "version": "6.4.11", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", @@ -87,6 +245,16 @@ "@lezer/html": "^1.3.12" } }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, "node_modules/@codemirror/lang-javascript": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", @@ -102,6 +270,61 @@ "@lezer/javascript": "^1.0.0" } }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", + "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", + "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, "node_modules/@codemirror/lang-markdown": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", @@ -117,6 +340,124 @@ "@lezer/markdown": "^1.0.0" } }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", @@ -131,6 +472,46 @@ "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", @@ -163,6 +544,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -605,6 +1126,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -661,6 +1200,17 @@ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/css": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", @@ -672,6 +1222,17 @@ "@lezer/lr": "^1.3.0" } }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -692,6 +1253,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/javascript": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", @@ -703,6 +1275,17 @@ "@lezer/lr": "^1.3.0" } }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", @@ -713,15 +1296,81 @@ } }, "node_modules/@lezer/markdown": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", - "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.1.tgz", + "integrity": "sha512-MEBZeFSBxgteUjEC3Wxg2Dwld5/JxRKG267L3bMFdibm8KjqSdiJYBeFw1Nt1CM8+zKMpSIEHblY8FD9z38sJQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -1446,18 +2095,122 @@ "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.8.0" + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", - "license": "MIT OR Apache-2.0", + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1635,6 +2388,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1665,6 +2441,16 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1744,6 +2530,41 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1762,6 +2583,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1782,6 +2610,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -1789,6 +2627,26 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1916,6 +2774,36 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1933,6 +2821,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1957,6 +2886,26 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1967,6 +2916,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2013,6 +2979,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2079,6 +3058,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2093,6 +3104,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -2151,6 +3186,19 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -2204,6 +3252,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2276,6 +3337,13 @@ "typescript": ">=5.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2337,6 +3405,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -2347,6 +3435,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2361,6 +3475,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -2565,6 +3689,54 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2582,6 +3754,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index c34b7ed..2b72163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantnotes", - "version": "0.7.0", + "version": "0.8.0", "description": "InstantNotes — instant capture, organized knowledge", "type": "module", "engines": { @@ -22,9 +22,11 @@ "@codemirror/commands": "^6", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6", "@codemirror/view": "^6", "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.7.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-process": "^2.3.1", @@ -35,7 +37,10 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", "@types/node": "^25.9.3", + "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 75e7486..6541683 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -4,9 +4,9 @@ // // npm run bump 0.6.0 // -// Updates: package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and -// the instantnotes entry in src-tauri/Cargo.lock. The core crate -// (src-tauri/core) versions independently and is intentionally left untouched. +// Updates: package.json, package-lock.json, src-tauri/tauri.conf.json, +// src-tauri/Cargo.toml, and the instantnotes entry in src-tauri/Cargo.lock. The +// core crate (src-tauri/core) versions independently and is left untouched. // Prints the next steps (commit, tag, push); it does not git-commit for you. import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -34,6 +34,19 @@ export function bumpPackageVersion(text, name, next) { return text.replace(re, `$1${next}$2`); } +/** + * Rewrite both instantnotes version fields in package-lock.json: the root + * `"version"` and the `packages[""].version` mirror of it. Both sit directly + * after `"name": "instantnotes"`, so anchoring on that leaves every dependency's + * `"version"` untouched. The global flag catches both entries in one pass. + */ +export function bumpLockVersion(text, next) { + return text.replace( + /("name":\s*"instantnotes",\s*"version":\s*")\d+\.\d+\.\d+(")/g, + `$1${next}$2`, + ); +} + /** Read the current top-level version from a JSON document. */ function readJsonVersion(text) { return text.match(/"version"\s*:\s*"(\d+\.\d+\.\d+)"/)?.[1] ?? null; @@ -57,6 +70,7 @@ function main(argv) { const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const pkgPath = join(root, "package.json"); + const pkgLockPath = join(root, "package-lock.json"); const confPath = join(root, "src-tauri", "tauri.conf.json"); const cargoPath = join(root, "src-tauri", "Cargo.toml"); const lockPath = join(root, "src-tauri", "Cargo.lock"); @@ -76,12 +90,13 @@ function main(argv) { } rewriteFile(pkgPath, (t) => bumpJsonVersion(t, next)); + rewriteFile(pkgLockPath, (t) => bumpLockVersion(t, next)); rewriteFile(confPath, (t) => bumpJsonVersion(t, next)); rewriteFile(cargoPath, (t) => bumpPackageVersion(t, "instantnotes", next)); rewriteFile(lockPath, (t) => bumpPackageVersion(t, "instantnotes", next)); console.log(`bumped ${current} -> ${next} in:`); - console.log(" package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock"); + console.log(" package.json, package-lock.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, src-tauri/Cargo.lock"); console.log(""); console.log("next:"); console.log(` 1. add a "## [${next}]" section to CHANGELOG.md`); diff --git a/scripts/bump-version.test.ts b/scripts/bump-version.test.ts index cd341d6..cf30f8e 100644 --- a/scripts/bump-version.test.ts +++ b/scripts/bump-version.test.ts @@ -3,6 +3,7 @@ import { isValidVersion, bumpJsonVersion, bumpPackageVersion, + bumpLockVersion, } from "./bump-version.mjs"; describe("isValidVersion", () => { @@ -64,3 +65,31 @@ describe("bumpPackageVersion", () => { expect(out).toContain('name = "instantnotes"\nversion = "0.6.0"'); }); }); + +describe("bumpLockVersion", () => { + it("rewrites both the root and packages[\"\"] version, leaving dependency versions alone", () => { + const lock = [ + "{", + ' "name": "instantnotes",', + ' "version": "0.5.2",', + ' "lockfileVersion": 3,', + ' "packages": {', + ' "": {', + ' "name": "instantnotes",', + ' "version": "0.5.2",', + ' "license": "MIT"', + " },", + ' "node_modules/svelte": {', + ' "version": "5.0.0"', + " }", + " }", + "}", + ].join("\n"); + const out = bumpLockVersion(lock, "0.6.0"); + // Both instantnotes version fields move. + expect(out.match(/"version": "0\.6\.0"/g)).toHaveLength(2); + expect(out).not.toContain('"version": "0.5.2"'); + // The dependency's version is untouched. + expect(out).toContain('"version": "5.0.0"'); + }); +}); diff --git a/scripts/make-update-manifest.sh b/scripts/make-update-manifest.sh index 4e61a38..32493ea 100755 --- a/scripts/make-update-manifest.sh +++ b/scripts/make-update-manifest.sh @@ -1,12 +1,25 @@ #!/usr/bin/env bash # Build latest.json for the in-app updater from a signed release build. # +# ============================ macOS-ONLY FALLBACK ============================= +# This writes a latest.json with ONLY the darwin-aarch64 platform. Since 0.7.0 +# ships Windows and Linux too, publishing this manifest to a real release would +# strand every non-macOS install (their updater reads a manifest that omits +# their platform). Use it only for a local, macOS-only test build. The real +# multi-platform manifest is assembled by .github/workflows/release.yml, which +# merges all three platforms; ship releases with a tag push, not this script. +# ============================================================================= +# # Run after `npm run tauri build` (with TAURI_SIGNING_PRIVATE_KEY_PATH set so # the .sig exists), then upload latest.json AND InstantNotes.app.tar.gz to the # GitHub release. The app polls: # https://github.com/Jam-Sw/InstantNotes/releases/latest/download/latest.json set -euo pipefail +echo "warning: this writes a macOS-only (darwin-aarch64) latest.json. Do NOT use" >&2 +echo "it to publish a multi-platform release; it would break Windows and Linux" >&2 +echo "updaters. For real releases push a v* tag and let release.yml build." >&2 + REPO="Jam-Sw/InstantNotes" BUNDLE_DIR="src-tauri/target/release/bundle/macos" ARCHIVE="$BUNDLE_DIR/InstantNotes.app.tar.gz" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b7cbe08..dc8b651 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1596,6 +1596,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -1856,7 +1862,7 @@ dependencies = [ [[package]] name = "instantnotes" -version = "0.7.0" +version = "0.8.0" dependencies = [ "instantnotes-core", "serde", @@ -1869,12 +1875,13 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "uuid", "window-vibrancy", ] [[package]] name = "instantnotes-core" -version = "0.4.0" +version = "0.8.0" dependencies = [ "chrono", "rusqlite", @@ -3777,6 +3784,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "image", "jni 0.21.1", "libc", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 333d1a8..cfd83ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "instantnotes" -version = "0.7.0" +version = "0.8.0" description = "InstantNotes — instant capture, organized knowledge" edition = "2021" @@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "macos-private-api", "image-png"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api", "image-png"] } tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" @@ -25,6 +25,7 @@ tauri-plugin-single-instance = "2" instantnotes-core = { path = "core" } serde = { version = "1", features = ["derive"] } serde_json = "1" +uuid = { version = "1", features = ["v4"] } tauri-plugin-dialog = "2" # Native macOS window materials (Liquid Glass / vibrancy). macOS-only; the app diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index ca0ee43..dfec658 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "instantnotes-core" -version = "0.4.0" -description = "InstantNotes core — domain logic, persistence, search. No UI, no Tauri." +version = "0.8.0" +description = "InstantNotes core: domain logic, persistence, search. No UI, no Tauri." edition = "2021" [dependencies] diff --git a/src-tauri/core/src/domain.rs b/src-tauri/core/src/domain.rs index 71b7aca..1e5ace7 100644 --- a/src-tauri/core/src/domain.rs +++ b/src-tauri/core/src/domain.rs @@ -66,8 +66,9 @@ pub fn extract_inline_tags(body: &str) -> Vec { out } -/// Derive a note title from the first non-empty line of the body per CAP-004: -/// strip leading markdown markers (`#`, `-`, `*`, `>`) and inline `#` tag +/// Derive a note title from the first non-empty line of the body (see +/// DATA_MODEL.md section 6): strip leading markdown markers (`#`, `-`, `*`, +/// `>`) and inline `#` tag /// prefixes, collapse whitespace, truncate to 80 chars (char boundary). /// Empty body yields "Untitled". pub fn derive_title(body: &str) -> String { @@ -143,7 +144,10 @@ mod tests { #[test] fn hash_mid_word_is_not_a_tag() { - assert_eq!(extract_inline_tags("the C#language a#b"), Vec::::new()); + assert_eq!( + extract_inline_tags("the C#language a#b"), + Vec::::new() + ); } #[test] @@ -175,7 +179,10 @@ mod tests { #[test] fn title_strips_tag_hashes_keeping_words() { - assert_eq!(derive_title("call sam #q3-budget now"), "call sam q3-budget now"); + assert_eq!( + derive_title("call sam #q3-budget now"), + "call sam q3-budget now" + ); } #[test] diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs index e42a3da..ae36909 100644 --- a/src-tauri/core/src/error.rs +++ b/src-tauri/core/src/error.rs @@ -13,6 +13,11 @@ pub enum AppError { Storage(String), #[error("{0}")] Migration(String), + /// A damaged or unreadable database file. Kept distinct from `Storage` so + /// recovery can act on it, but reports the same external error code per + /// API.md §11 (it is a storage failure to callers). + #[error("{0}")] + Corruption(String), } impl AppError { @@ -24,15 +29,32 @@ impl AppError { AppError::Conflict(_) => "CONFLICT", AppError::Storage(_) => "STORAGE_ERROR", AppError::Migration(_) => "MIGRATION_ERROR", + AppError::Corruption(_) => "STORAGE_ERROR", } } + + /// True for a damaged/unreadable database file. `Store::open_or_recover` + /// keys off this to decide a file is safe to set aside and start fresh. + pub fn is_corruption(&self) -> bool { + matches!(self, AppError::Corruption(_)) + } } impl From for AppError { fn from(e: rusqlite::Error) -> Self { - match e { + match &e { rusqlite::Error::QueryReturnedNoRows => AppError::NotFound("not found".into()), - other => AppError::Storage(other.to_string()), + // SQLITE_CORRUPT / SQLITE_NOTADB mean the file itself is unusable, + // not a transient lock or a logical error; mark it recoverable. + rusqlite::Error::SqliteFailure(err, _) + if matches!( + err.code, + rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase + ) => + { + AppError::Corruption(e.to_string()) + } + _ => AppError::Storage(e.to_string()), } } } @@ -50,5 +72,12 @@ mod tests { assert_eq!(AppError::Conflict("x".into()).code(), "CONFLICT"); assert_eq!(AppError::Storage("x".into()).code(), "STORAGE_ERROR"); assert_eq!(AppError::Migration("x".into()).code(), "MIGRATION_ERROR"); + assert_eq!(AppError::Corruption("x".into()).code(), "STORAGE_ERROR"); + } + + #[test] + fn only_corruption_reports_corruption() { + assert!(AppError::Corruption("x".into()).is_corruption()); + assert!(!AppError::Storage("x".into()).is_corruption()); } } diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index fc93d6a..30506d6 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -7,10 +7,12 @@ use crate::error::{AppError, Result}; use crate::types::*; use chrono::{SecondsFormat, Utc}; use rusqlite::{params, Connection, OptionalExtension}; -use std::path::Path; +use std::path::{Path, PathBuf}; use uuid::Uuid; -const MIGRATIONS: &[&str] = &[ +/// Ordered schema migrations; user_version tracks how many have run. Public so +/// tests can build fixtures at a historical schema version. +pub const MIGRATIONS: &[&str] = &[ // v1 — initial schema r#" CREATE TABLE notes ( @@ -95,11 +97,18 @@ CREATE TABLE note_workspaces ( PRIMARY KEY (note_id, workspace_id) ); CREATE INDEX idx_note_workspaces_ws ON note_workspaces(workspace_id); +"#, + // v3: drop the unused sync scaffolding. These columns were written but + // never read; a real sync feature will design its own schema when it lands. + r#" +ALTER TABLE notes DROP COLUMN sync_state; +ALTER TABLE notes DROP COLUMN version; +ALTER TABLE notes DROP COLUMN last_synced_at; "#, ]; const NOTE_COLUMNS: &str = "id, title, body, created_at, updated_at, last_opened_at, \ - is_pinned, is_archived, is_deleted, deleted_at, sync_state, version, last_synced_at"; + is_pinned, is_archived, is_deleted, deleted_at"; pub struct Store { conn: Connection, @@ -125,9 +134,6 @@ fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result { is_archived: row.get::<_, i64>(7)? != 0, is_deleted: row.get::<_, i64>(8)? != 0, deleted_at: row.get(9)?, - sync_state: row.get(10)?, - version: row.get(11)?, - last_synced_at: row.get(12)?, }) } @@ -230,15 +236,43 @@ impl Store { // surfaces to the user as a hard "database is locked" error. conn.busy_timeout(std::time::Duration::from_secs(5)) .map_err(|e| AppError::Storage(format!("cannot set busy timeout: {e}")))?; - conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get::<_, String>(0))?; + // A filesystem that refuses WAL (some network mounts) leaves the + // connection silently in rollback mode, defeating the crash-safety this + // app relies on; treat that as an unusable storage location. + let journal_mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?; + if !journal_mode.eq_ignore_ascii_case("wal") { + return Err(AppError::Storage(format!( + "storage location does not support WAL journaling (got '{journal_mode}')" + ))); + } // NORMAL is the standard, crash-safe pairing with WAL: fsync at // checkpoints rather than on every commit. Safe against app crashes; only // an OS crash or power loss can drop commits still sitting in the WAL. conn.pragma_update(None, "synchronous", "NORMAL") .map_err(|e| AppError::Storage(format!("cannot set synchronous mode: {e}")))?; + // Snapshot an existing library before it is migrated so a failed or + // buggy migration is always recoverable. + Self::backup_before_migration(path, &conn)?; Self::init(conn) } + /// Open the store, recovering from a corrupt database file by setting it + /// aside and starting fresh. The returned bool is true only when recovery + /// happened. Non-corruption failures (permissions, a WAL-hostile mount) + /// propagate unchanged so a transient or fixable problem never discards + /// good data. + pub fn open_or_recover(path: &Path) -> Result<(Self, bool)> { + match Self::open(path) { + Ok(store) => Ok((store, false)), + Err(e) if e.is_corruption() => { + Self::move_corrupt_aside(path)?; + let store = Self::open(path)?; + Ok((store, true)) + } + Err(e) => Err(e), + } + } + /// In-memory store for tests that don't need restart semantics. pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory() @@ -250,7 +284,7 @@ impl Store { conn.pragma_update(None, "foreign_keys", "ON")?; let check: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?; if check != "ok" { - return Err(AppError::Storage(format!( + return Err(AppError::Corruption(format!( "database integrity check failed: {check}" ))); } @@ -263,6 +297,16 @@ impl Store { let current: i64 = self .conn .query_row("PRAGMA user_version", [], |r| r.get(0))?; + // A user_version past the last known migration means this file was + // written by a newer build; its schema is unknown to us, so refuse + // rather than run queries that assume the older shape. + if current > MIGRATIONS.len() as i64 { + return Err(AppError::Migration(format!( + "database schema v{current} was created by a newer version of \ + the app (this build knows up to v{})", + MIGRATIONS.len() + ))); + } for (idx, sql) in MIGRATIONS.iter().enumerate() { let target = (idx + 1) as i64; if target <= current { @@ -282,528 +326,87 @@ impl Store { Ok(()) } - fn fetch_note(&self, id: &str) -> Result { - self.conn - .query_row( - &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), - params![id], - row_to_note, - ) - .optional()? - .ok_or_else(|| AppError::NotFound(format!("note {id} not found"))) - } - - // ---- notes ---- - - pub fn create_note(&mut self, input: CreateNoteInput) -> Result { - let body = input.body.unwrap_or_default(); - let explicit_title = input - .title - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()); - let title_is_auto = explicit_title.is_none(); - let title = explicit_title.unwrap_or_else(|| domain::derive_title(&body)); - let inline_tags = domain::extract_inline_tags(&body); - let now = now_iso(); - let id = new_id(); - - let tx = self.conn.transaction()?; - tx.execute( - "INSERT INTO notes (id, title, title_is_auto, body, created_at, updated_at, \ - sync_state, version) VALUES (?1, ?2, ?3, ?4, ?5, ?5, 'local_only', 1)", - params![id, title, i64::from(title_is_auto), body, now], - )?; - for name in &inline_tags { - let tag = tag_get_or_create(&tx, name)?; - attach_tag(&tx, &id, &tag.id, "inline")?; - } - for name in &input.tags { - let tag = tag_get_or_create(&tx, name)?; - attach_tag(&tx, &id, &tag.id, "manual")?; - } - tx.commit()?; - self.fetch_note(&id) - } - - /// Fetch a note. When `touch` is true, updates `last_opened_at`. - pub fn get_note(&mut self, id: &str, touch: bool) -> Result { - if touch { - self.conn.execute( - "UPDATE notes SET last_opened_at = ?1 WHERE id = ?2", - params![now_iso(), id], - )?; + /// Copy an existing library aside before migrating it. Runs only for a file + /// that already carries a schema older than the current one (0 < v < len); + /// a brand-new file has nothing to lose and a current file is not migrated. + /// A backup failure fails the open rather than migrating without a net. + fn backup_before_migration(path: &Path, conn: &Connection) -> Result<()> { + let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + if current <= 0 || current >= MIGRATIONS.len() as i64 { + return Ok(()); } - self.fetch_note(id) - } - - pub fn update_note(&mut self, id: &str, patch: UpdateNotePatch) -> Result { - // Ensure existence first for a clean NOT_FOUND. - self.fetch_note(id)?; - let title_is_auto: bool = self - .conn - .query_row( - "SELECT title_is_auto FROM notes WHERE id = ?1", - params![id], - |r| r.get::<_, i64>(0), - ) - .map(|v| v != 0)?; - - // An explicit title pins the title; an auto title follows body edits. - let explicit_title = patch - .title - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()); - let (new_title, new_title_is_auto) = match (&explicit_title, &patch.body) { - (Some(t), _) => (Some(t.clone()), Some(false)), - (None, Some(body)) if title_is_auto => (Some(domain::derive_title(body)), None), - _ => (None, None), - }; - - let now = now_iso(); - let tx = self.conn.transaction()?; - tx.execute( - "UPDATE notes SET \ - title = COALESCE(?1, title), \ - title_is_auto = COALESCE(?2, title_is_auto), \ - body = COALESCE(?3, body), \ - is_pinned = COALESCE(?4, is_pinned), \ - is_archived = COALESCE(?5, is_archived), \ - updated_at = ?6, \ - version = version + 1 \ - WHERE id = ?7", - params![ - new_title, - new_title_is_auto.map(i64::from), - patch.body.as_deref(), - patch.is_pinned.map(i64::from), - patch.is_archived.map(i64::from), - now, - id - ], - )?; - if let Some(body) = &patch.body { - for name in domain::extract_inline_tags(body) { - let tag = tag_get_or_create(&tx, &name)?; - attach_tag(&tx, id, &tag.id, "inline")?; - } + let mut backup = path.as_os_str().to_os_string(); + backup.push(format!(".backup-v{current}")); + let backup_path = PathBuf::from(backup); + // VACUUM INTO refuses to overwrite; clear any leftover from a prior + // interrupted attempt first. + if backup_path.exists() { + std::fs::remove_file(&backup_path) + .map_err(|e| AppError::Storage(format!("cannot clear stale backup: {e}")))?; } - tx.commit()?; - self.fetch_note(id) - } - - pub fn soft_delete_note(&mut self, id: &str) -> Result { - self.fetch_note(id)?; - let now = now_iso(); - self.conn.execute( - "UPDATE notes SET is_deleted = 1, deleted_at = ?1, updated_at = ?1, \ - version = version + 1 WHERE id = ?2", - params![now, id], - )?; - self.fetch_note(id) - } - - pub fn restore_note(&mut self, id: &str) -> Result { - self.fetch_note(id)?; - let now = now_iso(); - self.conn.execute( - "UPDATE notes SET is_deleted = 0, deleted_at = NULL, updated_at = ?1, \ - version = version + 1 WHERE id = ?2", - params![now, id], - )?; - self.fetch_note(id) - } - - /// Permanent deletion requires `confirm == true` (VALIDATION_ERROR otherwise). - pub fn permanently_delete_note(&mut self, id: &str, confirm: bool) -> Result<()> { - if !confirm { - return Err(AppError::Validation( - "permanent deletion requires explicit confirmation".into(), - )); - } - self.fetch_note(id)?; - // FK cascade removes note_tags; the AFTER DELETE trigger removes FTS rows. - self.conn - .execute("DELETE FROM notes WHERE id = ?1", params![id])?; + // The path is interpolated as a SQL string literal, so double any single + // quotes it contains. + let escaped = backup_path.to_string_lossy().replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{escaped}'")) + .map_err(|e| match AppError::from(e) { + // A corrupt source keeps its classification so open_or_recover + // can still set the file aside instead of giving up. + AppError::Corruption(msg) => { + AppError::Corruption(format!("pre-migration backup failed: {msg}")) + } + other => AppError::Storage(format!("pre-migration backup failed: {other}")), + })?; Ok(()) } - /// Default filter excludes archived and deleted notes; sorts by - /// updatedAt desc. - pub fn list_notes(&self, filter: NoteFilter) -> Result> { - let mut conditions: Vec = Vec::new(); - let mut args: Vec> = Vec::new(); - - let deleted = filter.is_deleted.unwrap_or(false); - conditions.push("is_deleted = ?".into()); - args.push(Box::new(i64::from(deleted))); - - if !deleted { - conditions.push("is_archived = ?".into()); - args.push(Box::new(i64::from(filter.is_archived.unwrap_or(false)))); - } else if let Some(archived) = filter.is_archived { - conditions.push("is_archived = ?".into()); - args.push(Box::new(i64::from(archived))); - } - - if let Some(pinned) = filter.is_pinned { - conditions.push("is_pinned = ?".into()); - args.push(Box::new(i64::from(pinned))); - } - - if let Some(workspace_id) = &filter.workspace_id { - conditions.push( - "id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = ?)".into(), - ); - args.push(Box::new(workspace_id.clone())); - } - - if !filter.tag_ids.is_empty() { - let placeholders = vec!["?"; filter.tag_ids.len()].join(", "); - conditions.push(format!( - "id IN (SELECT note_id FROM note_tags WHERE tag_id IN ({placeholders}))" - )); - for tag_id in &filter.tag_ids { - args.push(Box::new(tag_id.clone())); + /// Rename a corrupt database and its WAL/SHM siblings to a free + /// ".corrupt-N" suffix so a fresh store can be created at the same path + /// without clobbering the salvaged file. + fn move_corrupt_aside(path: &Path) -> Result<()> { + let mut n = 1; + let target = loop { + let mut candidate = path.as_os_str().to_os_string(); + candidate.push(format!(".corrupt-{n}")); + let candidate = PathBuf::from(candidate); + if !candidate.exists() { + break candidate; } - } - - if let Some(query) = filter.query.as_ref().filter(|q| !q.trim().is_empty()) { - conditions.push("(title LIKE ? OR body LIKE ?)".into()); - let like = format!("%{}%", query.trim()); - args.push(Box::new(like.clone())); - args.push(Box::new(like)); - } - - let order_column = match filter.sort_by.as_deref() { - Some("createdAt") => "created_at", - Some("lastOpenedAt") => "last_opened_at", - Some("title") => "title COLLATE NOCASE", - _ => "updated_at", + n += 1; }; - let order_dir = match filter.sort_order.as_deref() { - Some("asc") => "ASC", - _ => "DESC", - }; - let limit = filter.limit.unwrap_or(500).clamp(1, 5000); - let offset = filter.offset.unwrap_or(0).max(0); - - // Pinned notes float to the top of every live list; trash keeps - // plain recency order. - let pinned_first = if deleted { "" } else { "is_pinned DESC, " }; - let sql = format!( - "SELECT {NOTE_COLUMNS} FROM notes WHERE {} \ - ORDER BY {pinned_first}{order_column} {order_dir} \ - LIMIT {limit} OFFSET {offset}", - conditions.join(" AND ") - ); - let mut stmt = self.conn.prepare(&sql)?; - let rows = stmt.query_map( - rusqlite::params_from_iter(args.iter().map(|a| a.as_ref())), - row_to_note, - )?; - Ok(rows.collect::>>()?) - } - - /// Full-text search over title+body. Always excludes deleted notes; - /// excludes archived notes. Special characters in `text` must not error. - pub fn search_notes(&self, text: &str, limit: i64) -> Result> { - let Some(match_expr) = fts_match_expr(text) else { - return Ok(Vec::new()); - }; - let limit = limit.clamp(1, 500); - let mut stmt = self.conn.prepare( - "SELECT n.id, n.title, snippet(notes_fts, 1, '', '', '…', 12), \ - bm25(notes_fts), n.updated_at \ - FROM notes_fts \ - JOIN notes n ON n.seq = notes_fts.rowid \ - WHERE notes_fts MATCH ?1 AND n.is_deleted = 0 AND n.is_archived = 0 \ - ORDER BY bm25(notes_fts) \ - LIMIT ?2", - )?; - let rows = stmt.query_map(params![match_expr, limit], |row| { - Ok(SearchResult { - note_id: row.get(0)?, - title: row.get(1)?, - excerpt: row.get(2)?, - // bm25: lower is better (negative); expose higher-is-better. - score: -row.get::<_, f64>(3)?, - updated_at: row.get(4)?, - }) - })?; - Ok(rows.collect::>>()?) - } - - // ---- tags ---- - - pub fn get_or_create_tag(&mut self, name: &str) -> Result { - tag_get_or_create(&self.conn, name) - } - - pub fn list_tags(&self) -> Result> { - let mut stmt = self.conn.prepare( - "SELECT t.id, t.name, t.color, t.created_at, t.updated_at, \ - (SELECT COUNT(*) FROM note_tags nt \ - JOIN notes n ON n.id = nt.note_id \ - WHERE nt.tag_id = t.id AND n.is_deleted = 0) AS usage_count \ - FROM tags t ORDER BY t.name", - )?; - let rows = stmt.query_map([], |row| { - Ok(TagWithCount { - tag: row_to_tag(row)?, - usage_count: row.get(5)?, - }) - })?; - Ok(rows.collect::>>()?) - } - - pub fn update_tag( - &mut self, - id: &str, - name: Option, - color: Option, - ) -> Result { - let existing = self - .conn - .query_row( - "SELECT id, name, color, created_at, updated_at FROM tags WHERE id = ?1", - params![id], - row_to_tag, - ) - .optional()? - .ok_or_else(|| AppError::NotFound(format!("tag {id} not found")))?; - - let new_name = match name { - Some(raw) => { - let normalized = domain::normalize_tag_name(&raw) - .ok_or_else(|| AppError::Validation("tag name must not be empty".into()))?; - let clash: Option = self - .conn - .query_row( - "SELECT id FROM tags WHERE name = ?1 AND id <> ?2", - params![normalized, id], - |r| r.get(0), - ) - .optional()?; - if clash.is_some() { - return Err(AppError::Conflict(format!( - "a tag named '{normalized}' already exists" - ))); - } - normalized + std::fs::rename(path, &target) + .map_err(|e| AppError::Storage(format!("cannot set corrupt database aside: {e}")))?; + // WAL/SHM belong to the corrupt file; move them out of the way too so + // the fresh database starts clean. They may be absent. + for ext in ["-wal", "-shm"] { + let mut sibling = path.as_os_str().to_os_string(); + sibling.push(ext); + let sibling = PathBuf::from(sibling); + if sibling.exists() { + let mut sibling_target = target.as_os_str().to_os_string(); + sibling_target.push(ext); + let _ = std::fs::rename(&sibling, PathBuf::from(sibling_target)); } - None => existing.name, - }; - - self.conn.execute( - "UPDATE tags SET name = ?1, color = COALESCE(?2, color), updated_at = ?3 WHERE id = ?4", - params![new_name, color, now_iso(), id], - )?; - self.conn - .query_row( - "SELECT id, name, color, created_at, updated_at FROM tags WHERE id = ?1", - params![id], - row_to_tag, - ) - .map_err(Into::into) - } - - /// Removes the tag and its associations; notes are untouched. - pub fn delete_tag(&mut self, id: &str) -> Result<()> { - let affected = self - .conn - .execute("DELETE FROM tags WHERE id = ?1", params![id])?; - if affected == 0 { - return Err(AppError::NotFound(format!("tag {id} not found"))); } Ok(()) } - pub fn add_tag_to_note(&mut self, note_id: &str, name: &str) -> Result { - self.fetch_note(note_id)?; - let tag = tag_get_or_create(&self.conn, name)?; - attach_tag(&self.conn, note_id, &tag.id, "manual")?; - Ok(tag) - } - - pub fn remove_tag_from_note(&mut self, note_id: &str, tag_id: &str) -> Result<()> { - self.conn.execute( - "DELETE FROM note_tags WHERE note_id = ?1 AND tag_id = ?2", - params![note_id, tag_id], - )?; - Ok(()) - } - - pub fn tags_for_note(&self, note_id: &str) -> Result> { - let mut stmt = self.conn.prepare( - "SELECT t.id, t.name, t.color, t.created_at, t.updated_at \ - FROM tags t JOIN note_tags nt ON nt.tag_id = t.id \ - WHERE nt.note_id = ?1 ORDER BY t.name", - )?; - let rows = stmt.query_map(params![note_id], row_to_tag)?; - Ok(rows.collect::>>()?) - } - - // ---- workspaces ---- - - fn fetch_workspace(&self, id: &str) -> Result { + fn fetch_note(&self, id: &str) -> Result { self.conn .query_row( - &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE id = ?1"), + &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), params![id], - row_to_workspace, - ) - .optional()? - .ok_or_else(|| AppError::NotFound(format!("workspace {id} not found"))) - } - - pub fn get_or_create_workspace(&mut self, raw_name: &str) -> Result { - let name = domain::normalize_workspace_name(raw_name) - .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; - if let Some(ws) = self - .conn - .query_row( - &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE name = ?1"), - params![name], - row_to_workspace, + row_to_note, ) .optional()? - { - return Ok(ws); - } - let now = now_iso(); - let id = new_id(); - self.conn.execute( - "INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", - params![id, name, now], - )?; - Ok(Workspace { - id, - name, - created_at: now.clone(), - updated_at: now, - }) - } - - pub fn list_workspaces(&self) -> Result> { - let mut stmt = self.conn.prepare( - "SELECT w.id, w.name, w.created_at, w.updated_at, \ - (SELECT COUNT(*) FROM note_workspaces nw \ - JOIN notes n ON n.id = nw.note_id \ - WHERE nw.workspace_id = w.id AND n.is_deleted = 0) AS note_count \ - FROM workspaces w ORDER BY w.name COLLATE NOCASE", - )?; - let rows = stmt.query_map([], |row| { - Ok(WorkspaceWithCount { - workspace: row_to_workspace(row)?, - note_count: row.get(4)?, - }) - })?; - Ok(rows.collect::>>()?) - } - - pub fn rename_workspace(&mut self, id: &str, raw_name: &str) -> Result { - self.fetch_workspace(id)?; - let name = domain::normalize_workspace_name(raw_name) - .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; - let clash: Option = self - .conn - .query_row( - "SELECT id FROM workspaces WHERE name = ?1 AND id <> ?2", - params![name, id], - |r| r.get(0), - ) - .optional()?; - if clash.is_some() { - return Err(AppError::Conflict(format!( - "a workspace named '{name}' already exists" - ))); - } - self.conn.execute( - "UPDATE workspaces SET name = ?1, updated_at = ?2 WHERE id = ?3", - params![name, now_iso(), id], - )?; - self.fetch_workspace(id) - } - - /// Removes the workspace and its memberships; notes are untouched. - pub fn delete_workspace(&mut self, id: &str) -> Result<()> { - let affected = self - .conn - .execute("DELETE FROM workspaces WHERE id = ?1", params![id])?; - if affected == 0 { - return Err(AppError::NotFound(format!("workspace {id} not found"))); - } - Ok(()) - } - - /// Collect a note into a workspace (idempotent). - pub fn add_note_to_workspace(&mut self, note_id: &str, workspace_id: &str) -> Result<()> { - self.fetch_note(note_id)?; - self.fetch_workspace(workspace_id)?; - self.conn.execute( - "INSERT OR IGNORE INTO note_workspaces (note_id, workspace_id, created_at) \ - VALUES (?1, ?2, ?3)", - params![note_id, workspace_id, now_iso()], - )?; - Ok(()) - } - - pub fn remove_note_from_workspace(&mut self, note_id: &str, workspace_id: &str) -> Result<()> { - self.conn.execute( - "DELETE FROM note_workspaces WHERE note_id = ?1 AND workspace_id = ?2", - params![note_id, workspace_id], - )?; - Ok(()) - } - - pub fn workspaces_for_note(&self, note_id: &str) -> Result> { - let mut stmt = self.conn.prepare( - "SELECT w.id, w.name, w.created_at, w.updated_at \ - FROM workspaces w JOIN note_workspaces nw ON nw.workspace_id = w.id \ - WHERE nw.note_id = ?1 ORDER BY w.name COLLATE NOCASE", - )?; - let rows = stmt.query_map(params![note_id], row_to_workspace)?; - Ok(rows.collect::>>()?) - } - - // ---- settings ---- - - pub fn get_setting(&self, key: &str) -> Result> { - let raw: Option = self - .conn - .query_row( - "SELECT value FROM settings WHERE key = ?1", - params![key], - |r| r.get(0), - ) - .optional()?; - match raw { - None => Ok(None), - Some(s) => serde_json::from_str(&s) - .map(Some) - .map_err(|e| AppError::Storage(format!("corrupt setting '{key}': {e}"))), - } - } - - pub fn set_setting(&mut self, key: &str, value: serde_json::Value) -> Result<()> { - let serialized = serde_json::to_string(&value) - .map_err(|e| AppError::Validation(format!("unserializable setting value: {e}")))?; - self.conn.execute( - "INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3) \ - ON CONFLICT(key) DO UPDATE SET value = excluded.value, \ - updated_at = excluded.updated_at", - params![key, serialized, now_iso()], - )?; - Ok(()) - } - - pub fn delete_setting(&mut self, key: &str) -> Result<()> { - self.conn - .execute("DELETE FROM settings WHERE key = ?1", params![key])?; - Ok(()) + .ok_or_else(|| AppError::NotFound(format!("note {id} not found"))) } } +mod notes; +mod settings; +mod tags; +mod workspaces; + #[cfg(test)] mod pragma_tests { use super::Store; @@ -837,3 +440,60 @@ mod pragma_tests { assert_eq!(synchronous, 1); } } + +#[cfg(test)] +mod migration_tests { + use super::{Store, MIGRATIONS}; + use rusqlite::Connection; + use tempfile::tempdir; + + fn note_columns(conn: &Connection) -> Vec { + let mut stmt = conn.prepare("PRAGMA table_info(notes)").unwrap(); + let cols: Vec = stmt + .query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + cols + } + + /// A database written by a pre-0.8 build (schema v2) still carries the sync + /// columns. Opening it runs the v3 migration, which must drop them without + /// losing any note. + #[test] + fn v3_drops_sync_columns_and_preserves_notes() { + let dir = tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + + // Build a v2 database by hand, exactly as an older build left it. + { + let conn = Connection::open(&path).unwrap(); + conn.execute_batch(MIGRATIONS[0]).unwrap(); + conn.execute_batch(MIGRATIONS[1]).unwrap(); + conn.pragma_update(None, "user_version", 2i64).unwrap(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, \ + sync_state, version) \ + VALUES ('n1', 'Kept', 'the body', 't', 't', 'local_only', 3)", + [], + ) + .unwrap(); + assert!(note_columns(&conn).contains(&"sync_state".to_string())); + } + + // Opening runs the pending v3 migration. + let mut store = Store::open(&path).unwrap(); + + let cols = note_columns(&store.conn); + assert!(!cols.contains(&"sync_state".to_string())); + assert!(!cols.contains(&"version".to_string())); + assert!(!cols.contains(&"last_synced_at".to_string())); + + // The note and its content survived the column drop. + let note = store.get_note("n1", false).unwrap(); + assert_eq!(note.title, "Kept"); + assert_eq!(note.body, "the body"); + let all = store.list_notes(Default::default()).unwrap(); + assert!(all.iter().any(|n| n.id == "n1")); + } +} diff --git a/src-tauri/core/src/store/notes.rs b/src-tauri/core/src/store/notes.rs new file mode 100644 index 0000000..9ad2a15 --- /dev/null +++ b/src-tauri/core/src/store/notes.rs @@ -0,0 +1,395 @@ +//! Note CRUD, listing, and full-text search. + +use super::*; + +impl Store { + pub fn create_note(&mut self, input: CreateNoteInput) -> Result { + let body = input.body.unwrap_or_default(); + let explicit_title = input + .title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + let title_is_auto = explicit_title.is_none(); + let title = explicit_title.unwrap_or_else(|| domain::derive_title(&body)); + let inline_tags = domain::extract_inline_tags(&body); + let now = now_iso(); + let id = new_id(); + + let tx = self.conn.transaction()?; + tx.execute( + "INSERT INTO notes (id, title, title_is_auto, body, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + params![id, title, i64::from(title_is_auto), body, now], + )?; + for name in &inline_tags { + let tag = tag_get_or_create(&tx, name)?; + attach_tag(&tx, &id, &tag.id, "inline")?; + } + for name in &input.tags { + let tag = tag_get_or_create(&tx, name)?; + attach_tag(&tx, &id, &tag.id, "manual")?; + } + tx.commit()?; + self.fetch_note(&id) + } + + /// Fetch a note. When `touch` is true, updates `last_opened_at`. + pub fn get_note(&mut self, id: &str, touch: bool) -> Result { + if touch { + self.conn.execute( + "UPDATE notes SET last_opened_at = ?1 WHERE id = ?2", + params![now_iso(), id], + )?; + } + self.fetch_note(id) + } + + pub fn update_note(&mut self, id: &str, patch: UpdateNotePatch) -> Result { + // Ensure existence first for a clean NOT_FOUND. + let existing = self.fetch_note(id)?; + // An empty patch is a no-op: skip the UPDATE so updated_at is not + // bumped and recency-sorted lists keep their order. + if patch.title.is_none() + && patch.body.is_none() + && patch.is_pinned.is_none() + && patch.is_archived.is_none() + { + return Ok(existing); + } + let title_is_auto: bool = self + .conn + .query_row( + "SELECT title_is_auto FROM notes WHERE id = ?1", + params![id], + |r| r.get::<_, i64>(0), + ) + .map(|v| v != 0)?; + + // An explicit title pins the title; an auto title follows body edits. + let explicit_title = patch + .title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + let (new_title, new_title_is_auto) = match (&explicit_title, &patch.body) { + (Some(t), _) => (Some(t.clone()), Some(false)), + (None, Some(body)) if title_is_auto => (Some(domain::derive_title(body)), None), + _ => (None, None), + }; + + let now = now_iso(); + let tx = self.conn.transaction()?; + tx.execute( + "UPDATE notes SET \ + title = COALESCE(?1, title), \ + title_is_auto = COALESCE(?2, title_is_auto), \ + body = COALESCE(?3, body), \ + is_pinned = COALESCE(?4, is_pinned), \ + is_archived = COALESCE(?5, is_archived), \ + updated_at = ?6 \ + WHERE id = ?7", + params![ + new_title, + new_title_is_auto.map(i64::from), + patch.body.as_deref(), + patch.is_pinned.map(i64::from), + patch.is_archived.map(i64::from), + now, + id + ], + )?; + if let Some(body) = &patch.body { + // Reconcile inline tags with the new body: attach the tags it now + // mentions, then detach any inline-sourced edge whose #token is + // gone so removing a tag chip is not undone by the next save. + // Manual edges are pinned and never touched by a body edit. + let mut kept_ids: Vec = Vec::new(); + for name in domain::extract_inline_tags(body) { + let tag = tag_get_or_create(&tx, &name)?; + attach_tag(&tx, id, &tag.id, "inline")?; + kept_ids.push(tag.id); + } + if kept_ids.is_empty() { + tx.execute( + "DELETE FROM note_tags WHERE note_id = ?1 AND source = 'inline'", + params![id], + )?; + } else { + let placeholders = vec!["?"; kept_ids.len()].join(", "); + let sql = format!( + "DELETE FROM note_tags WHERE note_id = ? AND source = 'inline' \ + AND tag_id NOT IN ({placeholders})" + ); + let mut args: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(kept_ids.len() + 1); + args.push(&id); + for tag_id in &kept_ids { + args.push(tag_id); + } + tx.execute(&sql, rusqlite::params_from_iter(args))?; + } + } + tx.commit()?; + self.fetch_note(id) + } + + pub fn soft_delete_note(&mut self, id: &str) -> Result { + self.fetch_note(id)?; + let now = now_iso(); + self.conn.execute( + "UPDATE notes SET is_deleted = 1, deleted_at = ?1, updated_at = ?1 \ + WHERE id = ?2", + params![now, id], + )?; + self.fetch_note(id) + } + + pub fn restore_note(&mut self, id: &str) -> Result { + self.fetch_note(id)?; + let now = now_iso(); + self.conn.execute( + "UPDATE notes SET is_deleted = 0, deleted_at = NULL, updated_at = ?1 \ + WHERE id = ?2", + params![now, id], + )?; + self.fetch_note(id) + } + + /// Permanent deletion requires `confirm == true` (VALIDATION_ERROR otherwise). + pub fn permanently_delete_note(&mut self, id: &str, confirm: bool) -> Result<()> { + if !confirm { + return Err(AppError::Validation( + "permanent deletion requires explicit confirmation".into(), + )); + } + self.fetch_note(id)?; + // FK cascade removes note_tags; the AFTER DELETE trigger removes FTS rows. + self.conn + .execute("DELETE FROM notes WHERE id = ?1", params![id])?; + Ok(()) + } + + /// Default filter excludes archived and deleted notes; sorts by + /// updatedAt desc. + pub fn list_notes(&self, filter: NoteFilter) -> Result> { + let mut conditions: Vec = Vec::new(); + let mut args: Vec> = Vec::new(); + + let deleted = filter.is_deleted.unwrap_or(false); + conditions.push("is_deleted = ?".into()); + args.push(Box::new(i64::from(deleted))); + + if !deleted { + conditions.push("is_archived = ?".into()); + args.push(Box::new(i64::from(filter.is_archived.unwrap_or(false)))); + } else if let Some(archived) = filter.is_archived { + conditions.push("is_archived = ?".into()); + args.push(Box::new(i64::from(archived))); + } + + if let Some(pinned) = filter.is_pinned { + conditions.push("is_pinned = ?".into()); + args.push(Box::new(i64::from(pinned))); + } + + if filter.never_opened == Some(true) { + conditions.push("last_opened_at IS NULL".into()); + } + + if let Some(created_before) = &filter.created_before { + // Timestamps are stored as UTC ISO-8601, so string comparison is + // chronological; differing sub-second precision only moves the + // boundary within a second, which no caller depends on. + conditions.push("created_at < ?".into()); + args.push(Box::new(created_before.clone())); + } + + if let Some(workspace_id) = &filter.workspace_id { + conditions + .push("id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = ?)".into()); + args.push(Box::new(workspace_id.clone())); + } + + if !filter.tag_ids.is_empty() { + let placeholders = vec!["?"; filter.tag_ids.len()].join(", "); + conditions.push(format!( + "id IN (SELECT note_id FROM note_tags WHERE tag_id IN ({placeholders}))" + )); + for tag_id in &filter.tag_ids { + args.push(Box::new(tag_id.clone())); + } + } + + if let Some(query) = filter.query.as_ref().filter(|q| !q.trim().is_empty()) { + conditions.push("(title LIKE ? OR body LIKE ?)".into()); + let like = format!("%{}%", query.trim()); + args.push(Box::new(like.clone())); + args.push(Box::new(like)); + } + + let order_column = match filter.sort_by.as_deref() { + Some("createdAt") => "created_at", + Some("lastOpenedAt") => "last_opened_at", + Some("title") => "title COLLATE NOCASE", + _ => "updated_at", + }; + let order_dir = match filter.sort_order.as_deref() { + Some("asc") => "ASC", + _ => "DESC", + }; + let limit = filter.limit.unwrap_or(500).clamp(1, 5000); + let offset = filter.offset.unwrap_or(0).max(0); + + // Pinned notes float to the top of every live list; trash keeps + // plain recency order. + let pinned_first = if deleted { "" } else { "is_pinned DESC, " }; + let sql = format!( + "SELECT {NOTE_COLUMNS} FROM notes WHERE {} \ + ORDER BY {pinned_first}{order_column} {order_dir} \ + LIMIT {limit} OFFSET {offset}", + conditions.join(" AND ") + ); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(args.iter().map(|a| a.as_ref())), + row_to_note, + )?; + Ok(rows.collect::>>()?) + } + + /// Full-text search over title+body. Always excludes deleted notes; + /// excludes archived notes. Special characters in `text` must not error. + /// Title and excerpt matches are bracketed with U+0001 (start) / U+0002 + /// (end) sentinels rather than HTML: both are control characters a user + /// can never type, so the frontend can split on them unambiguously to + /// highlight hits as plain-text segments (see `highlight.ts`). + pub fn search_notes(&self, text: &str, limit: i64) -> Result> { + let Some(match_expr) = fts_match_expr(text) else { + return Ok(Vec::new()); + }; + let limit = limit.clamp(1, 500); + let mut stmt = self.conn.prepare( + // 16 tokens, not the FTS5 default 15 or the prior 12: the list + // row is single-line and CSS-truncated regardless, so a wider + // window costs nothing visually and gives multi-word queries + // enough room for more than one matched term to land together. + // highlight() (not snippet()) for the title: titles are short, so + // the full column with markers is what the row renders anyway. A + // query matching only the title still shows why the note hit. + "SELECT n.id, highlight(notes_fts, 0, '\u{1}', '\u{2}'), \ + snippet(notes_fts, 1, '\u{1}', '\u{2}', '…', 16), \ + bm25(notes_fts), n.updated_at \ + FROM notes_fts \ + JOIN notes n ON n.seq = notes_fts.rowid \ + WHERE notes_fts MATCH ?1 AND n.is_deleted = 0 AND n.is_archived = 0 \ + ORDER BY bm25(notes_fts) \ + LIMIT ?2", + )?; + let rows = stmt.query_map(params![match_expr, limit], |row| { + Ok(SearchResult { + note_id: row.get(0)?, + title: row.get(1)?, + excerpt: row.get(2)?, + // bm25: lower is better (negative); expose higher-is-better. + score: -row.get::<_, f64>(3)?, + updated_at: row.get(4)?, + }) + })?; + Ok(rows.collect::>>()?) + } + + // ---- bulk operations ---- + // One statement over an id set, so a multi-select action is a single + // transaction and a single change event instead of one per note. + + fn id_placeholders(ids: &[String]) -> String { + vec!["?"; ids.len()].join(", ") + } + + /// Set pin and/or archive flags on many notes at once. `None` leaves a + /// flag untouched. + pub fn set_notes_flags( + &mut self, + ids: &[String], + is_pinned: Option, + is_archived: Option, + ) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + let now = now_iso(); + let pinned = is_pinned.map(i64::from); + let archived = is_archived.map(i64::from); + let sql = format!( + "UPDATE notes SET \ + is_pinned = COALESCE(?, is_pinned), \ + is_archived = COALESCE(?, is_archived), \ + updated_at = ? \ + WHERE id IN ({})", + Self::id_placeholders(ids) + ); + let mut args: Vec<&dyn rusqlite::ToSql> = vec![&pinned, &archived, &now]; + for id in ids { + args.push(id); + } + self.conn.execute(&sql, rusqlite::params_from_iter(args))?; + Ok(()) + } + + /// Move many notes to trash at once. + pub fn soft_delete_notes(&mut self, ids: &[String]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + let now = now_iso(); + let sql = format!( + "UPDATE notes SET is_deleted = 1, deleted_at = ?, updated_at = ? \ + WHERE id IN ({})", + Self::id_placeholders(ids) + ); + let mut args: Vec<&dyn rusqlite::ToSql> = vec![&now, &now]; + for id in ids { + args.push(id); + } + self.conn.execute(&sql, rusqlite::params_from_iter(args))?; + Ok(()) + } + + /// Restore many trashed notes at once. + pub fn restore_notes(&mut self, ids: &[String]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + let now = now_iso(); + let sql = format!( + "UPDATE notes SET is_deleted = 0, deleted_at = NULL, updated_at = ? \ + WHERE id IN ({})", + Self::id_placeholders(ids) + ); + let mut args: Vec<&dyn rusqlite::ToSql> = vec![&now]; + for id in ids { + args.push(id); + } + self.conn.execute(&sql, rusqlite::params_from_iter(args))?; + Ok(()) + } + + /// Permanently delete many notes at once. Requires `confirm == true` + /// (VALIDATION_ERROR otherwise); cascades clear tag and workspace edges. + pub fn destroy_notes(&mut self, ids: &[String], confirm: bool) -> Result<()> { + if !confirm { + return Err(AppError::Validation( + "permanent deletion requires confirmation".into(), + )); + } + if ids.is_empty() { + return Ok(()); + } + let sql = format!( + "DELETE FROM notes WHERE id IN ({})", + Self::id_placeholders(ids) + ); + let args: Vec<&dyn rusqlite::ToSql> = + ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); + self.conn.execute(&sql, rusqlite::params_from_iter(args))?; + Ok(()) + } +} diff --git a/src-tauri/core/src/store/settings.rs b/src-tauri/core/src/store/settings.rs new file mode 100644 index 0000000..943c67d --- /dev/null +++ b/src-tauri/core/src/store/settings.rs @@ -0,0 +1,40 @@ +//! The key/value settings store. + +use super::*; + +impl Store { + pub fn get_setting(&self, key: &str) -> Result> { + let raw: Option = self + .conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |r| r.get(0), + ) + .optional()?; + match raw { + None => Ok(None), + Some(s) => serde_json::from_str(&s) + .map(Some) + .map_err(|e| AppError::Storage(format!("corrupt setting '{key}': {e}"))), + } + } + + pub fn set_setting(&mut self, key: &str, value: serde_json::Value) -> Result<()> { + let serialized = serde_json::to_string(&value) + .map_err(|e| AppError::Validation(format!("unserializable setting value: {e}")))?; + self.conn.execute( + "INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value, \ + updated_at = excluded.updated_at", + params![key, serialized, now_iso()], + )?; + Ok(()) + } + + pub fn delete_setting(&mut self, key: &str) -> Result<()> { + self.conn + .execute("DELETE FROM settings WHERE key = ?1", params![key])?; + Ok(()) + } +} diff --git a/src-tauri/core/src/store/tags.rs b/src-tauri/core/src/store/tags.rs new file mode 100644 index 0000000..b1b3856 --- /dev/null +++ b/src-tauri/core/src/store/tags.rs @@ -0,0 +1,121 @@ +//! Tag CRUD and note-tag associations. + +use super::*; + +impl Store { + pub fn get_or_create_tag(&mut self, name: &str) -> Result { + tag_get_or_create(&self.conn, name) + } + + pub fn list_tags(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.name, t.color, t.created_at, t.updated_at, \ + (SELECT COUNT(*) FROM note_tags nt \ + JOIN notes n ON n.id = nt.note_id \ + WHERE nt.tag_id = t.id AND n.is_deleted = 0) AS usage_count \ + FROM tags t ORDER BY t.name", + )?; + let rows = stmt.query_map([], |row| { + Ok(TagWithCount { + tag: row_to_tag(row)?, + usage_count: row.get(5)?, + }) + })?; + Ok(rows.collect::>>()?) + } + + pub fn update_tag( + &mut self, + id: &str, + name: Option, + color: Option, + ) -> Result { + let existing = self + .conn + .query_row( + "SELECT id, name, color, created_at, updated_at FROM tags WHERE id = ?1", + params![id], + row_to_tag, + ) + .optional()? + .ok_or_else(|| AppError::NotFound(format!("tag {id} not found")))?; + + let new_name = match name { + Some(raw) => { + let normalized = domain::normalize_tag_name(&raw) + .ok_or_else(|| AppError::Validation("tag name must not be empty".into()))?; + let clash: Option = self + .conn + .query_row( + "SELECT id FROM tags WHERE name = ?1 AND id <> ?2", + params![normalized, id], + |r| r.get(0), + ) + .optional()?; + if clash.is_some() { + return Err(AppError::Conflict(format!( + "a tag named '{normalized}' already exists" + ))); + } + normalized + } + None => existing.name, + }; + + self.conn.execute( + "UPDATE tags SET name = ?1, color = COALESCE(?2, color), updated_at = ?3 WHERE id = ?4", + params![new_name, color, now_iso(), id], + )?; + self.conn + .query_row( + "SELECT id, name, color, created_at, updated_at FROM tags WHERE id = ?1", + params![id], + row_to_tag, + ) + .map_err(Into::into) + } + + /// Removes the tag and its associations; notes are untouched. + pub fn delete_tag(&mut self, id: &str) -> Result<()> { + let affected = self + .conn + .execute("DELETE FROM tags WHERE id = ?1", params![id])?; + if affected == 0 { + return Err(AppError::NotFound(format!("tag {id} not found"))); + } + Ok(()) + } + + pub fn add_tag_to_note(&mut self, note_id: &str, name: &str) -> Result { + self.fetch_note(note_id)?; + let tag = tag_get_or_create(&self.conn, name)?; + attach_tag(&self.conn, note_id, &tag.id, "manual")?; + // attach_tag is INSERT OR IGNORE, so an edge already present as 'inline' + // keeps that source. An explicit add is a pin, so promote it to + // 'manual' and inline reconciliation will no longer detach it. + self.conn.execute( + "UPDATE note_tags SET source = 'manual' \ + WHERE note_id = ?1 AND tag_id = ?2 AND source = 'inline'", + params![note_id, tag.id], + )?; + Ok(tag) + } + + pub fn remove_tag_from_note(&mut self, note_id: &str, tag_id: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM note_tags WHERE note_id = ?1 AND tag_id = ?2", + params![note_id, tag_id], + )?; + Ok(()) + } + + pub fn tags_for_note(&self, note_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT t.id, t.name, t.color, t.created_at, t.updated_at \ + FROM tags t JOIN note_tags nt ON nt.tag_id = t.id \ + WHERE nt.note_id = ?1 ORDER BY t.name", + )?; + let rows = stmt.query_map(params![note_id], row_to_tag)?; + Ok(rows.collect::>>()?) + } +} diff --git a/src-tauri/core/src/store/workspaces.rs b/src-tauri/core/src/store/workspaces.rs new file mode 100644 index 0000000..9477f5c --- /dev/null +++ b/src-tauri/core/src/store/workspaces.rs @@ -0,0 +1,158 @@ +//! Workspace CRUD and note-workspace membership. + +use super::*; + +impl Store { + fn fetch_workspace(&self, id: &str) -> Result { + self.conn + .query_row( + &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE id = ?1"), + params![id], + row_to_workspace, + ) + .optional()? + .ok_or_else(|| AppError::NotFound(format!("workspace {id} not found"))) + } + + pub fn get_or_create_workspace(&mut self, raw_name: &str) -> Result { + let name = domain::normalize_workspace_name(raw_name) + .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; + if let Some(ws) = self + .conn + .query_row( + &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE name = ?1"), + params![name], + row_to_workspace, + ) + .optional()? + { + return Ok(ws); + } + let now = now_iso(); + let id = new_id(); + self.conn.execute( + "INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + params![id, name, now], + )?; + Ok(Workspace { + id, + name, + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn list_workspaces(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT w.id, w.name, w.created_at, w.updated_at, \ + (SELECT COUNT(*) FROM note_workspaces nw \ + JOIN notes n ON n.id = nw.note_id \ + WHERE nw.workspace_id = w.id AND n.is_deleted = 0) AS note_count \ + FROM workspaces w ORDER BY w.name COLLATE NOCASE", + )?; + let rows = stmt.query_map([], |row| { + Ok(WorkspaceWithCount { + workspace: row_to_workspace(row)?, + note_count: row.get(4)?, + }) + })?; + Ok(rows.collect::>>()?) + } + + pub fn rename_workspace(&mut self, id: &str, raw_name: &str) -> Result { + self.fetch_workspace(id)?; + let name = domain::normalize_workspace_name(raw_name) + .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; + let clash: Option = self + .conn + .query_row( + "SELECT id FROM workspaces WHERE name = ?1 AND id <> ?2", + params![name, id], + |r| r.get(0), + ) + .optional()?; + if clash.is_some() { + return Err(AppError::Conflict(format!( + "a workspace named '{name}' already exists" + ))); + } + self.conn.execute( + "UPDATE workspaces SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![name, now_iso(), id], + )?; + self.fetch_workspace(id) + } + + /// Removes the workspace and its memberships; notes are untouched. + /// Returns the member note ids so the caller can offer an undo that + /// re-adds every membership: a post-hoc `list_notes` snapshot can't, + /// because its default filter hides archived and trashed members. + pub fn delete_workspace(&mut self, id: &str) -> Result> { + self.fetch_workspace(id)?; + let member_ids = { + let mut stmt = self + .conn + .prepare("SELECT note_id FROM note_workspaces WHERE workspace_id = ?1")?; + let rows = stmt.query_map(params![id], |r| r.get(0))?; + rows.collect::>>()? + }; + self.conn + .execute("DELETE FROM workspaces WHERE id = ?1", params![id])?; + Ok(member_ids) + } + + /// Tags carried by a workspace's visible notes, with counts scoped to + /// the workspace (the note list's tag chips). Archived and trashed + /// members don't contribute: a chip must never filter the visible + /// list down to zero matches for a tag the user can't see. + pub fn list_workspace_tags(&self, workspace_id: &str) -> Result> { + self.fetch_workspace(workspace_id)?; + let mut stmt = self.conn.prepare( + "SELECT t.id, t.name, t.color, t.created_at, t.updated_at, \ + COUNT(*) AS usage_count \ + FROM tags t \ + JOIN note_tags nt ON nt.tag_id = t.id \ + JOIN note_workspaces nw ON nw.note_id = nt.note_id \ + JOIN notes n ON n.id = nt.note_id \ + WHERE nw.workspace_id = ?1 AND n.is_deleted = 0 AND n.is_archived = 0 \ + GROUP BY t.id ORDER BY t.name", + )?; + let rows = stmt.query_map(params![workspace_id], |row| { + Ok(TagWithCount { + tag: row_to_tag(row)?, + usage_count: row.get(5)?, + }) + })?; + Ok(rows.collect::>>()?) + } + + /// Collect a note into a workspace (idempotent). + pub fn add_note_to_workspace(&mut self, note_id: &str, workspace_id: &str) -> Result<()> { + self.fetch_note(note_id)?; + self.fetch_workspace(workspace_id)?; + self.conn.execute( + "INSERT OR IGNORE INTO note_workspaces (note_id, workspace_id, created_at) \ + VALUES (?1, ?2, ?3)", + params![note_id, workspace_id, now_iso()], + )?; + Ok(()) + } + + pub fn remove_note_from_workspace(&mut self, note_id: &str, workspace_id: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM note_workspaces WHERE note_id = ?1 AND workspace_id = ?2", + params![note_id, workspace_id], + )?; + Ok(()) + } + + pub fn workspaces_for_note(&self, note_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT w.id, w.name, w.created_at, w.updated_at \ + FROM workspaces w JOIN note_workspaces nw ON nw.workspace_id = w.id \ + WHERE nw.note_id = ?1 ORDER BY w.name COLLATE NOCASE", + )?; + let rows = stmt.query_map(params![note_id], row_to_workspace)?; + Ok(rows.collect::>>()?) + } +} diff --git a/src-tauri/core/src/types.rs b/src-tauri/core/src/types.rs index f182657..6821359 100644 --- a/src-tauri/core/src/types.rs +++ b/src-tauri/core/src/types.rs @@ -14,9 +14,6 @@ pub struct Note { pub is_archived: bool, pub is_deleted: bool, pub deleted_at: Option, - pub sync_state: String, - pub version: i64, - pub last_synced_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -83,6 +80,11 @@ pub struct NoteFilter { pub is_pinned: Option, pub is_archived: Option, pub is_deleted: Option, + /// Only notes never opened in the library (capture-born, untriaged). + /// Drives the Revisit view; opening a note releases it from the filter. + pub never_opened: Option, + /// Only notes created strictly before this ISO-8601 timestamp. + pub created_before: Option, pub sort_by: Option, pub sort_order: Option, pub limit: Option, diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index 798cf75..e5dd47e 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -1,6 +1,7 @@ //! Integration tests against real SQLite (tempfile / in-memory). //! Synthetic fixtures only, per TEST_PLAN.md §5. +use instantnotes_core::store::MIGRATIONS; use instantnotes_core::types::*; use instantnotes_core::{AppError, Store}; @@ -25,8 +26,6 @@ fn create_sets_defaults_per_data_model() { let n = create(&mut s, "Buy milk and coffee"); assert!(!n.id.is_empty()); assert_eq!(n.title, "Buy milk and coffee"); - assert_eq!(n.version, 1); - assert_eq!(n.sync_state, "local_only"); assert!(!n.is_pinned && !n.is_archived && !n.is_deleted); assert!(!n.created_at.is_empty()); assert_eq!(n.created_at, n.updated_at); @@ -91,7 +90,7 @@ fn get_with_touch_sets_last_opened_at() { // ---- update ---- #[test] -fn update_body_increments_version() { +fn update_body_persists_new_body() { let mut s = store(); let n = create(&mut s, "draft one"); let u = s @@ -103,7 +102,6 @@ fn update_body_increments_version() { }, ) .unwrap(); - assert_eq!(u.version, 2); assert_eq!(u.body, "draft two"); } @@ -128,6 +126,99 @@ fn update_body_attaches_new_inline_tags() { assert!(names.contains(&"newtag".to_string())); } +#[test] +fn removing_inline_token_detaches_tag_but_keeps_tag_row() { + let mut s = store(); + let n = create(&mut s, "notes on #alpha and #beta"); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("notes on #beta only".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["beta".to_string()]); + // The tag itself survives, just unused. + let alpha = s + .list_tags() + .unwrap() + .into_iter() + .find(|t| t.tag.name == "alpha") + .expect("alpha tag row should survive detachment"); + assert_eq!(alpha.usage_count, 0); + + // A body with no tokens at all clears every inline edge. + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("no tags anymore".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(s.tags_for_note(&n.id).unwrap().is_empty()); +} + +#[test] +fn manually_added_tag_survives_body_edits() { + let mut s = store(); + let n = create(&mut s, "plain body"); + s.add_tag_to_note(&n.id, "pinned").unwrap(); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("edited body, still no tokens".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["pinned".to_string()]); +} + +#[test] +fn explicit_add_promotes_inline_tag_past_token_removal() { + let mut s = store(); + let n = create(&mut s, "working on #keeper today"); + // The explicit add pins the already-inline tag against body edits. + s.add_tag_to_note(&n.id, "keeper").unwrap(); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("token removed from body".into()), + ..Default::default() + }, + ) + .unwrap(); + let names: Vec = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, vec!["keeper".to_string()]); +} + +#[test] +fn empty_patch_does_not_bump_updated_at() { + let mut s = store(); + let n = create(&mut s, "leave me alone"); + let u = s.update_note(&n.id, UpdateNotePatch::default()).unwrap(); + assert_eq!(u.updated_at, n.updated_at); +} + #[test] fn auto_derived_title_follows_body_updates() { let mut s = store(); @@ -407,6 +498,93 @@ fn search_special_characters_do_not_error() { } } +#[test] +fn search_excerpt_brackets_the_matched_term_with_sentinels() { + let mut s = store(); + create(&mut s, "Travel checklist\npassport tickets sunscreen"); + let hits = s.search_notes("passport", 10).unwrap(); + assert_eq!(hits.len(), 1); + let excerpt = &hits[0].excerpt; + let start = excerpt + .find('\u{1}') + .unwrap_or_else(|| panic!("missing start sentinel in {excerpt:?}")); + let end = excerpt + .find('\u{2}') + .unwrap_or_else(|| panic!("missing end sentinel in {excerpt:?}")); + assert!( + start < end, + "start sentinel should precede end: {excerpt:?}" + ); + let hit_text = &excerpt[start + '\u{1}'.len_utf8()..end]; + assert_eq!(hit_text.to_lowercase(), "passport"); +} + +#[test] +fn search_excerpt_marks_every_term_in_a_multi_word_query() { + let mut s = store(); + create( + &mut s, + "Travel checklist\npassport tickets sunscreen and a boarding pass", + ); + let hits = s.search_notes("passport tickets", 10).unwrap(); + assert_eq!(hits.len(), 1); + let excerpt = &hits[0].excerpt; + assert_eq!( + excerpt.matches('\u{1}').count(), + 2, + "expected both query terms marked: {excerpt:?}" + ); + assert_eq!(excerpt.matches('\u{2}').count(), 2); +} + +#[test] +fn search_excerpt_sentinels_are_always_balanced() { + let mut s = store(); + create( + &mut s, + "brainstorming session notes: more brainstorming, then a brainstorming recap", + ); + let hits = s.search_notes("brainstorming", 10).unwrap(); + assert_eq!(hits.len(), 1); + let excerpt = &hits[0].excerpt; + let starts = excerpt.matches('\u{1}').count(); + let ends = excerpt.matches('\u{2}').count(); + assert!(starts > 0, "expected at least one match: {excerpt:?}"); + assert_eq!(starts, ends, "sentinels should be balanced: {excerpt:?}"); +} + +#[test] +fn search_title_matches_are_bracketed_with_sentinels() { + let mut s = store(); + create(&mut s, "Travel checklist\npassport tickets sunscreen"); + let hits = s.search_notes("travel", 10).unwrap(); + assert_eq!(hits.len(), 1); + let title = &hits[0].title; + let start = title + .find('\u{1}') + .unwrap_or_else(|| panic!("missing start sentinel in {title:?}")); + let end = title + .find('\u{2}') + .unwrap_or_else(|| panic!("missing end sentinel in {title:?}")); + assert!(start < end, "start sentinel should precede end: {title:?}"); + let hit_text = &title[start + '\u{1}'.len_utf8()..end]; + assert_eq!(hit_text.to_lowercase(), "travel"); +} + +#[test] +fn search_title_without_a_match_carries_no_sentinels() { + let mut s = store(); + create(&mut s, "Travel checklist\npassport tickets sunscreen"); + let hits = s.search_notes("passport", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert!( + !hits[0].title.contains('\u{1}') && !hits[0].title.contains('\u{2}'), + "unmatched title should be marker-free: {:?}", + hits[0].title + ); + assert_eq!(hits[0].title, "Travel checklist"); +} + // ---- tags ---- #[test] @@ -456,7 +634,9 @@ fn rename_tag_normalizes_and_conflicts_error() { let _b = s.get_or_create_tag("beta").unwrap(); let renamed = s.update_tag(&a.id, Some("#Gamma".into()), None).unwrap(); assert_eq!(renamed.name, "gamma"); - let err = s.update_tag(&renamed.id, Some("beta".into()), None).unwrap_err(); + let err = s + .update_tag(&renamed.id, Some("beta".into()), None) + .unwrap_err(); assert_eq!(err.code(), "CONFLICT"); } @@ -526,10 +706,143 @@ fn notes_survive_reopen() { assert_eq!(names, vec!["idea".to_string()]); } +// ---- migrations / recovery ---- + +#[test] +fn migrate_refuses_user_version_above_known_migrations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("future.db"); + // A file stamped by a newer build: valid but with a schema version this + // build has never heard of. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.pragma_update(None, "user_version", (MIGRATIONS.len() + 1) as i64) + .unwrap(); + } + let err = match Store::open(&path) { + Ok(_) => panic!("open should refuse a future schema version"), + Err(e) => e, + }; + assert_eq!(err.code(), "MIGRATION_ERROR"); +} + +#[test] +fn open_migrates_v1_schema_and_leaves_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + // v1 fixture: only the first migration applied, user_version pinned at 1, + // exactly as an older build would have left the file. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch(MIGRATIONS[0]).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + } + let mut s = Store::open(&path).unwrap(); + // The v2 migration ran: workspaces (added in v2) is usable. + s.get_or_create_workspace("Migrated").unwrap(); + assert_eq!(s.list_workspaces().unwrap().len(), 1); + // And the pre-migration snapshot sits next to the database. + let backup = dir.path().join("legacy.db.backup-v1"); + assert!( + backup.exists(), + "expected pre-migration backup at {backup:?}" + ); +} + +#[test] +fn open_or_recover_sets_corrupt_file_aside_and_starts_fresh() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("garbage.db"); + std::fs::write(&path, b"\x7f not a sqlite database \x00\x01\x02garbage").unwrap(); + assert!( + Store::open(&path).is_err(), + "garbage must not open normally" + ); + + let (mut s, recovered) = Store::open_or_recover(&path).unwrap(); + assert!(recovered); + let n = create(&mut s, "fresh start"); + assert_eq!(s.get_note(&n.id, false).unwrap().id, n.id); + // The unreadable original was set aside, not destroyed. + assert!(dir.path().join("garbage.db.corrupt-1").exists()); +} + // keep AppError import used even if individual asserts change #[allow(dead_code)] fn _uses(_: AppError) {} +// ---- capture write-path perf smoke ---- + +#[test] +fn create_note_stays_fast_enough_for_capture() { + // An order-of-magnitude regression net for the capture write path, not a + // benchmark: the bound is generous so CI runners never flake, but an + // accidental full-table rescan or per-insert reindex would blow through it. + let mut s = store(); + for i in 0..200 { + create(&mut s, &format!("warmup note {i} #tag{}", i % 7)); + } + let start = std::time::Instant::now(); + create(&mut s, "capture perf probe #loop"); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_millis(250), + "single capture write took {elapsed:?}" + ); +} + +// ---- revisit filter (never opened + created before) ---- + +#[test] +fn never_opened_filter_releases_notes_once_touched() { + let mut s = store(); + let seen = create(&mut s, "capture that got read"); + let unseen = create(&mut s, "capture still waiting"); + // Opening with touch stamps last_opened_at and releases the note. + s.get_note(&seen.id, true).unwrap(); + + let filter = NoteFilter { + never_opened: Some(true), + ..Default::default() + }; + let loops = s.list_notes(filter).unwrap(); + let ids: Vec<_> = loops.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids, vec![unseen.id.as_str()]); + + // A plain get without touch must NOT release it. + s.get_note(&unseen.id, false).unwrap(); + let filter = NoteFilter { + never_opened: Some(true), + ..Default::default() + }; + assert_eq!(s.list_notes(filter).unwrap().len(), 1); +} + +#[test] +fn created_before_filter_is_a_strict_cutoff() { + let mut s = store(); + let n = create(&mut s, "old enough"); + let far_future = "2099-01-01T00:00:00Z".to_string(); + let far_past = "2000-01-01T00:00:00Z".to_string(); + + let hits = s + .list_notes(NoteFilter { + created_before: Some(far_future), + ..Default::default() + }) + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id, n.id); + + let hits = s + .list_notes(NoteFilter { + created_before: Some(far_past), + ..Default::default() + }) + .unwrap(); + assert!(hits.is_empty()); +} + // ---- workspaces ---- #[test] @@ -588,6 +901,80 @@ fn delete_workspace_keeps_notes() { assert!(matches!(err, AppError::NotFound(_))); } +#[test] +fn delete_workspace_returns_every_member_id_for_undo() { + let mut s = store(); + let ws = s.get_or_create_workspace("Disbanded").unwrap(); + let live = create(&mut s, "live member"); + let archived = create(&mut s, "archived member"); + let trashed = create(&mut s, "trashed member"); + for n in [&live, &archived, &trashed] { + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + } + s.update_note( + &archived.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + s.soft_delete_note(&trashed.id).unwrap(); + + let mut member_ids = s.delete_workspace(&ws.id).unwrap(); + member_ids.sort(); + let mut expected = vec![live.id.clone(), archived.id.clone(), trashed.id.clone()]; + expected.sort(); + assert_eq!(member_ids, expected); + + // The returned ids are enough to rebuild the space with full fidelity. + let again = s.get_or_create_workspace("Disbanded").unwrap(); + for id in &member_ids { + s.add_note_to_workspace(id, &again.id).unwrap(); + } + assert_eq!(s.workspaces_for_note(&trashed.id).unwrap().len(), 1); + assert_eq!(s.workspaces_for_note(&archived.id).unwrap().len(), 1); +} + +#[test] +fn list_workspace_tags_scopes_counts_to_visible_members() { + let mut s = store(); + let ws = s.get_or_create_workspace("To do").unwrap(); + let school = create(&mut s, "essay draft #school"); + let car = create(&mut s, "oil change #car"); + let gone = create(&mut s, "old chore #car"); + let _outside = create(&mut s, "unrelated #school"); + for n in [&school, &car, &gone] { + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + } + s.soft_delete_note(&gone.id).unwrap(); + + let tags = s.list_workspace_tags(&ws.id).unwrap(); + let summary: Vec<(&str, i64)> = tags + .iter() + .map(|t| (t.tag.name.as_str(), t.usage_count)) + .collect(); + // #car counts one member (the trashed one is invisible); the note + // outside the workspace never contributes to #school. + assert_eq!(summary, vec![("car", 1), ("school", 1)]); + + // Archived members drop out of the chips too. + s.update_note( + &car.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + let tags = s.list_workspace_tags(&ws.id).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag.name, "school"); + + let err = s.list_workspace_tags("missing-ws").unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); +} + #[test] fn workspace_membership_roundtrip() { let mut s = store(); @@ -665,3 +1052,40 @@ fn workspaces_survive_reopen() { let members = s2.workspaces_for_note(¬e_id).unwrap(); assert_eq!(members.len(), 1); } + +#[test] +fn bulk_soft_delete_then_restore_roundtrip() { + let mut s = store(); + let a = create(&mut s, "a"); + let b = create(&mut s, "b"); + let ids = vec![a.id.clone(), b.id.clone()]; + s.soft_delete_notes(&ids).unwrap(); + assert!(s.get_note(&a.id, false).unwrap().is_deleted); + assert!(s.get_note(&b.id, false).unwrap().is_deleted); + s.restore_notes(&ids).unwrap(); + assert!(!s.get_note(&a.id, false).unwrap().is_deleted); + assert!(!s.get_note(&b.id, false).unwrap().is_deleted); +} + +#[test] +fn bulk_set_flags_updates_all() { + let mut s = store(); + let a = create(&mut s, "a"); + let b = create(&mut s, "b"); + let ids = vec![a.id.clone(), b.id.clone()]; + s.set_notes_flags(&ids, Some(true), None).unwrap(); + assert!(s.get_note(&a.id, false).unwrap().is_pinned); + assert!(s.get_note(&b.id, false).unwrap().is_pinned); + assert!(!s.get_note(&a.id, false).unwrap().is_archived); +} + +#[test] +fn bulk_destroy_requires_confirm() { + let mut s = store(); + let a = create(&mut s, "a"); + let ids = vec![a.id.clone()]; + assert!(s.destroy_notes(&ids, false).is_err()); + assert!(s.get_note(&a.id, false).is_ok()); + s.destroy_notes(&ids, true).unwrap(); + assert!(s.get_note(&a.id, false).is_err()); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..977c3e5 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod notes; +pub mod settings; +pub mod tags; +pub mod workspaces; diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs new file mode 100644 index 0000000..264f2cd --- /dev/null +++ b/src-tauri/src/commands/notes.rs @@ -0,0 +1,127 @@ +//! Note commands: create, read, update, delete, list, search. + +use crate::*; + +#[tauri::command(async)] +pub fn create_note( + state: State<'_, AppState>, + app: AppHandle, + input: CreateNoteInput, +) -> CmdResult { + let note = locked(&state)?.create_note(input)?; + emit_notes_changed(&app); + emit_tags_changed(&app); + Ok(note) +} + +#[tauri::command(async)] +pub fn get_note(state: State<'_, AppState>, id: String, touch: Option) -> CmdResult { + Ok(locked(&state)?.get_note(&id, touch.unwrap_or(false))?) +} + +#[tauri::command(async)] +pub fn update_note( + state: State<'_, AppState>, + app: AppHandle, + id: String, + patch: UpdateNotePatch, +) -> CmdResult { + let note = locked(&state)?.update_note(&id, patch)?; + emit_notes_changed(&app); + emit_tags_changed(&app); + Ok(note) +} + +#[tauri::command(async)] +pub fn soft_delete_note(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult { + let note = locked(&state)?.soft_delete_note(&id)?; + emit_notes_changed(&app); + Ok(note) +} + +#[tauri::command(async)] +pub fn restore_note(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult { + let note = locked(&state)?.restore_note(&id)?; + emit_notes_changed(&app); + Ok(note) +} + +#[tauri::command(async)] +pub fn permanently_delete_note( + state: State<'_, AppState>, + app: AppHandle, + id: String, + confirm: bool, +) -> CmdResult<()> { + locked(&state)?.permanently_delete_note(&id, confirm)?; + emit_notes_changed(&app); + emit_tags_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn list_notes(state: State<'_, AppState>, filter: Option) -> CmdResult> { + Ok(locked(&state)?.list_notes(filter.unwrap_or_default())?) +} + +#[tauri::command(async)] +pub fn search_notes( + state: State<'_, AppState>, + text: String, + limit: Option, +) -> CmdResult> { + Ok(locked(&state)?.search_notes(&text, limit.unwrap_or(50))?) +} + +// ---- bulk commands ---- +// One transaction and one change event for a whole multi-select, instead of +// one round trip per note. + +#[tauri::command(async)] +pub fn set_notes_flags( + state: State<'_, AppState>, + app: AppHandle, + ids: Vec, + is_pinned: Option, + is_archived: Option, +) -> CmdResult<()> { + locked(&state)?.set_notes_flags(&ids, is_pinned, is_archived)?; + emit_notes_changed(&app); + emit_tags_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn soft_delete_notes( + state: State<'_, AppState>, + app: AppHandle, + ids: Vec, +) -> CmdResult<()> { + locked(&state)?.soft_delete_notes(&ids)?; + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn restore_notes( + state: State<'_, AppState>, + app: AppHandle, + ids: Vec, +) -> CmdResult<()> { + locked(&state)?.restore_notes(&ids)?; + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn destroy_notes( + state: State<'_, AppState>, + app: AppHandle, + ids: Vec, + confirm: bool, +) -> CmdResult<()> { + locked(&state)?.destroy_notes(&ids, confirm)?; + emit_notes_changed(&app); + emit_tags_changed(&app); + Ok(()) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..0ee2a65 --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,25 @@ +//! Settings key/value commands. + +use crate::*; + +#[tauri::command(async)] +pub fn get_setting( + state: State<'_, AppState>, + key: String, +) -> CmdResult> { + Ok(locked(&state)?.get_setting(&key)?) +} + +#[tauri::command(async)] +pub fn set_setting( + state: State<'_, AppState>, + key: String, + value: serde_json::Value, +) -> CmdResult<()> { + Ok(locked(&state)?.set_setting(&key, value)?) +} + +#[tauri::command(async)] +pub fn delete_setting(state: State<'_, AppState>, key: String) -> CmdResult<()> { + Ok(locked(&state)?.delete_setting(&key)?) +} diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs new file mode 100644 index 0000000..358e2a4 --- /dev/null +++ b/src-tauri/src/commands/tags.rs @@ -0,0 +1,71 @@ +//! Tag commands. + +use crate::*; + +#[tauri::command(async)] +pub fn list_tags(state: State<'_, AppState>) -> CmdResult> { + Ok(locked(&state)?.list_tags()?) +} + +#[tauri::command(async)] +pub fn get_or_create_tag( + state: State<'_, AppState>, + app: AppHandle, + name: String, +) -> CmdResult { + let tag = locked(&state)?.get_or_create_tag(&name)?; + emit_tags_changed(&app); + Ok(tag) +} + +#[tauri::command(async)] +pub fn update_tag( + state: State<'_, AppState>, + app: AppHandle, + id: String, + name: Option, + color: Option, +) -> CmdResult { + let tag = locked(&state)?.update_tag(&id, name, color)?; + emit_tags_changed(&app); + Ok(tag) +} + +#[tauri::command(async)] +pub fn delete_tag(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult<()> { + locked(&state)?.delete_tag(&id)?; + emit_tags_changed(&app); + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn add_tag_to_note( + state: State<'_, AppState>, + app: AppHandle, + note_id: String, + name: String, +) -> CmdResult { + let tag = locked(&state)?.add_tag_to_note(¬e_id, &name)?; + emit_tags_changed(&app); + emit_notes_changed(&app); + Ok(tag) +} + +#[tauri::command(async)] +pub fn remove_tag_from_note( + state: State<'_, AppState>, + app: AppHandle, + note_id: String, + tag_id: String, +) -> CmdResult<()> { + locked(&state)?.remove_tag_from_note(¬e_id, &tag_id)?; + emit_tags_changed(&app); + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn tags_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult> { + Ok(locked(&state)?.tags_for_note(¬e_id)?) +} diff --git a/src-tauri/src/commands/workspaces.rs b/src-tauri/src/commands/workspaces.rs new file mode 100644 index 0000000..4d04c57 --- /dev/null +++ b/src-tauri/src/commands/workspaces.rs @@ -0,0 +1,85 @@ +//! Workspace commands. + +use crate::*; + +#[tauri::command(async)] +pub fn list_workspaces(state: State<'_, AppState>) -> CmdResult> { + Ok(locked(&state)?.list_workspaces()?) +} + +#[tauri::command(async)] +pub fn get_or_create_workspace( + state: State<'_, AppState>, + app: AppHandle, + name: String, +) -> CmdResult { + let ws = locked(&state)?.get_or_create_workspace(&name)?; + emit_workspaces_changed(&app); + Ok(ws) +} + +#[tauri::command(async)] +pub fn rename_workspace( + state: State<'_, AppState>, + app: AppHandle, + id: String, + name: String, +) -> CmdResult { + let ws = locked(&state)?.rename_workspace(&id, &name)?; + emit_workspaces_changed(&app); + Ok(ws) +} + +#[tauri::command(async)] +pub fn delete_workspace( + state: State<'_, AppState>, + app: AppHandle, + id: String, +) -> CmdResult> { + let member_note_ids = locked(&state)?.delete_workspace(&id)?; + emit_workspaces_changed(&app); + emit_notes_changed(&app); + Ok(member_note_ids) +} + +#[tauri::command(async)] +pub fn list_workspace_tags( + state: State<'_, AppState>, + workspace_id: String, +) -> CmdResult> { + Ok(locked(&state)?.list_workspace_tags(&workspace_id)?) +} + +#[tauri::command(async)] +pub fn add_note_to_workspace( + state: State<'_, AppState>, + app: AppHandle, + note_id: String, + workspace_id: String, +) -> CmdResult<()> { + locked(&state)?.add_note_to_workspace(¬e_id, &workspace_id)?; + emit_workspaces_changed(&app); + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn remove_note_from_workspace( + state: State<'_, AppState>, + app: AppHandle, + note_id: String, + workspace_id: String, +) -> CmdResult<()> { + locked(&state)?.remove_note_from_workspace(¬e_id, &workspace_id)?; + emit_workspaces_changed(&app); + emit_notes_changed(&app); + Ok(()) +} + +#[tauri::command(async)] +pub fn workspaces_for_note( + state: State<'_, AppState>, + note_id: String, +) -> CmdResult> { + Ok(locked(&state)?.workspaces_for_note(¬e_id)?) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5009fde..999c4bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,10 +5,12 @@ use instantnotes_core::types::*; use instantnotes_core::{AppError, Store}; use serde::Serialize; +use std::sync::atomic::Ordering; use std::sync::Mutex; use tauri::menu::{Menu, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder}; use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_opener::OpenerExt; struct AppState { @@ -55,498 +57,20 @@ fn emit_workspaces_changed(app: &AppHandle) { let _ = app.emit("workspaces:changed", ()); } -// ---- note commands ---- +// ---- shortcut status ---- -#[tauri::command(async)] -fn create_note( - state: State<'_, AppState>, - app: AppHandle, - input: CreateNoteInput, -) -> CmdResult { - let note = locked(&state)?.create_note(input)?; - emit_notes_changed(&app); - emit_tags_changed(&app); - Ok(note) +/// Set once at startup when global-shortcut registration failed (another app +/// owns the hotkey). Queryable because the "shortcut:failed" event fires +/// before the library webview has listeners attached, so an event alone +/// would be lost. +pub(crate) struct ShortcutStatus { + failed: Option, } -#[tauri::command(async)] -fn get_note(state: State<'_, AppState>, id: String, touch: Option) -> CmdResult { - Ok(locked(&state)?.get_note(&id, touch.unwrap_or(false))?) -} - -#[tauri::command(async)] -fn update_note( - state: State<'_, AppState>, - app: AppHandle, - id: String, - patch: UpdateNotePatch, -) -> CmdResult { - let note = locked(&state)?.update_note(&id, patch)?; - emit_notes_changed(&app); - emit_tags_changed(&app); - Ok(note) -} - -#[tauri::command(async)] -fn soft_delete_note(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult { - let note = locked(&state)?.soft_delete_note(&id)?; - emit_notes_changed(&app); - Ok(note) -} - -#[tauri::command(async)] -fn restore_note(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult { - let note = locked(&state)?.restore_note(&id)?; - emit_notes_changed(&app); - Ok(note) -} - -#[tauri::command(async)] -fn permanently_delete_note( - state: State<'_, AppState>, - app: AppHandle, - id: String, - confirm: bool, -) -> CmdResult<()> { - locked(&state)?.permanently_delete_note(&id, confirm)?; - emit_notes_changed(&app); - emit_tags_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn list_notes(state: State<'_, AppState>, filter: Option) -> CmdResult> { - Ok(locked(&state)?.list_notes(filter.unwrap_or_default())?) -} - -#[tauri::command(async)] -fn search_notes( - state: State<'_, AppState>, - text: String, - limit: Option, -) -> CmdResult> { - Ok(locked(&state)?.search_notes(&text, limit.unwrap_or(50))?) -} - -// ---- tag commands ---- - -#[tauri::command(async)] -fn list_tags(state: State<'_, AppState>) -> CmdResult> { - Ok(locked(&state)?.list_tags()?) -} - -#[tauri::command(async)] -fn get_or_create_tag(state: State<'_, AppState>, app: AppHandle, name: String) -> CmdResult { - let tag = locked(&state)?.get_or_create_tag(&name)?; - emit_tags_changed(&app); - Ok(tag) -} - -#[tauri::command(async)] -fn update_tag( - state: State<'_, AppState>, - app: AppHandle, - id: String, - name: Option, - color: Option, -) -> CmdResult { - let tag = locked(&state)?.update_tag(&id, name, color)?; - emit_tags_changed(&app); - Ok(tag) -} - -#[tauri::command(async)] -fn delete_tag(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult<()> { - locked(&state)?.delete_tag(&id)?; - emit_tags_changed(&app); - emit_notes_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn add_tag_to_note( - state: State<'_, AppState>, - app: AppHandle, - note_id: String, - name: String, -) -> CmdResult { - let tag = locked(&state)?.add_tag_to_note(¬e_id, &name)?; - emit_tags_changed(&app); - emit_notes_changed(&app); - Ok(tag) -} - -#[tauri::command(async)] -fn remove_tag_from_note( - state: State<'_, AppState>, - app: AppHandle, - note_id: String, - tag_id: String, -) -> CmdResult<()> { - locked(&state)?.remove_tag_from_note(¬e_id, &tag_id)?; - emit_tags_changed(&app); - emit_notes_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn tags_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult> { - Ok(locked(&state)?.tags_for_note(¬e_id)?) -} - -// ---- workspace commands ---- - -#[tauri::command(async)] -fn list_workspaces(state: State<'_, AppState>) -> CmdResult> { - Ok(locked(&state)?.list_workspaces()?) -} - -#[tauri::command(async)] -fn get_or_create_workspace( - state: State<'_, AppState>, - app: AppHandle, - name: String, -) -> CmdResult { - let ws = locked(&state)?.get_or_create_workspace(&name)?; - emit_workspaces_changed(&app); - Ok(ws) -} - -#[tauri::command(async)] -fn rename_workspace( - state: State<'_, AppState>, - app: AppHandle, - id: String, - name: String, -) -> CmdResult { - let ws = locked(&state)?.rename_workspace(&id, &name)?; - emit_workspaces_changed(&app); - Ok(ws) -} - -#[tauri::command(async)] -fn delete_workspace(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult<()> { - locked(&state)?.delete_workspace(&id)?; - emit_workspaces_changed(&app); - emit_notes_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn add_note_to_workspace( - state: State<'_, AppState>, - app: AppHandle, - note_id: String, - workspace_id: String, -) -> CmdResult<()> { - locked(&state)?.add_note_to_workspace(¬e_id, &workspace_id)?; - emit_workspaces_changed(&app); - emit_notes_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn remove_note_from_workspace( - state: State<'_, AppState>, - app: AppHandle, - note_id: String, - workspace_id: String, -) -> CmdResult<()> { - locked(&state)?.remove_note_from_workspace(¬e_id, &workspace_id)?; - emit_workspaces_changed(&app); - emit_notes_changed(&app); - Ok(()) -} - -#[tauri::command(async)] -fn workspaces_for_note(state: State<'_, AppState>, note_id: String) -> CmdResult> { - Ok(locked(&state)?.workspaces_for_note(¬e_id)?) -} - -// ---- settings commands ---- - -#[tauri::command(async)] -fn get_setting(state: State<'_, AppState>, key: String) -> CmdResult> { - Ok(locked(&state)?.get_setting(&key)?) -} - -#[tauri::command(async)] -fn set_setting( - state: State<'_, AppState>, - key: String, - value: serde_json::Value, -) -> CmdResult<()> { - Ok(locked(&state)?.set_setting(&key, value)?) -} - -#[tauri::command(async)] -fn delete_setting(state: State<'_, AppState>, key: String) -> CmdResult<()> { - Ok(locked(&state)?.delete_setting(&key)?) -} - -// ---- window commands ---- - -// Window show/hide is instant and main-thread-friendly, so these stay synchronous -// (unlike the data/IO commands, which run async to keep off the UI thread). -#[tauri::command] -fn hide_capture(app: AppHandle) { - hide_capture_window(&app); -} - -#[tauri::command] -fn open_library(app: AppHandle) { - show_library_window(&app); -} - -/// Apply a native macOS vibrancy material to the library window, or clear it when -/// `material` is None/unknown. Vibrancy is the closest a webview app gets to the -/// Tahoe "Liquid Glass" look; it requires the always-transparent window and a -/// translucent surface above it (the theme's sidebar token). A no-op off macOS. -#[tauri::command] -fn set_window_vibrancy(app: AppHandle, material: Option) { - #[cfg(target_os = "macos")] - { - use window_vibrancy::{apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial}; - let Some(win) = app.get_webview_window("library") else { - return; - }; - // Any known material applies; None or an unknown string clears. - let chosen = material.as_deref().and_then(|m| match m { - "sidebar" => Some(NSVisualEffectMaterial::Sidebar), - "under-window" => Some(NSVisualEffectMaterial::UnderWindowBackground), - "header" => Some(NSVisualEffectMaterial::HeaderView), - "menu" => Some(NSVisualEffectMaterial::Menu), - "popover" => Some(NSVisualEffectMaterial::Popover), - "hud" => Some(NSVisualEffectMaterial::HudWindow), - _ => None, - }); - match chosen { - Some(m) => { - let _ = apply_vibrancy(&win, m, None, None); - } - None => { - let _ = clear_vibrancy(&win); - } - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = (app, material); - } -} - -/// Match the native library window's theme (titlebar and traffic-light treatment) -/// to the in-app light/dark variant. Tauri maps this to the window's OS appearance, -/// so the chrome follows the active theme instead of the launch-time system setting. -/// An unknown variant is a no-op; the borderless capture window has no native chrome -/// and is left alone. -#[tauri::command] -fn set_window_theme(app: AppHandle, variant: String) { - use tauri::Theme; - let theme = match variant.as_str() { - "light" => Theme::Light, - "dark" => Theme::Dark, - _ => return, - }; - if let Some(win) = app.get_webview_window("library") { - let _ = win.set_theme(Some(theme)); - } -} - -// ---- theme file sharing ---- -// Thin byte I/O for portable `.intheme.json` theme files. The open/save dialog -// runs in JS via the dialog plugin; Rust only reads/writes the chosen path, so -// no broad filesystem capability is needed. Validation happens in the webview -// before any token is applied. - -/// Reject anything that isn't an absolute path to a `.json` file. The path is -/// chosen by the user through a native save/open dialog but arrives here from the -/// webview, so this guard keeps the command from becoming a way to read or write -/// arbitrary files anywhere on disk. -fn validate_theme_path(path: &str) -> CmdResult<()> { - let p = std::path::Path::new(path); - if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme path must be absolute".into(), - }); - } - let is_json = p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("json")); - if !is_json { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme file must have a .json extension".into(), - }); - } - Ok(()) -} - -#[tauri::command(async)] -fn export_theme_file(path: String, contents: String) -> CmdResult<()> { - validate_theme_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write theme file: {e}"), - }) -} - -#[tauri::command(async)] -fn import_theme_file(path: String) -> CmdResult { - validate_theme_path(&path)?; - std::fs::read_to_string(&path).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not read theme file: {e}"), - }) -} - -// ---- note export ---- - -fn validate_export_path(path: &str) -> CmdResult<()> { - let p = std::path::Path::new(path); - if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export path must be absolute".into(), - }); - } - let is_allowed = p - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt")); - if !is_allowed { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export file must have a .md or .txt extension".into(), - }); - } - Ok(()) -} - -#[tauri::command(async)] -fn export_note_file(path: String, contents: String) -> CmdResult<()> { - validate_export_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write export file: {e}"), - }) -} - -const REPO_URL: &str = "https://github.com/Jam-Sw/InstantNotes"; - -fn open_data_folder(app: &AppHandle) { - if let Ok(dir) = app.path().app_data_dir() { - // Via the opener plugin rather than a raw `open` subprocess, so it stays - // on Tauri's permission-checked path. Called only from Rust with our own - // data directory - never a webview-supplied path. - let _ = app.opener().open_path(dir.to_string_lossy(), None::<&str>); - } -} - -// ---- icon cache refresh ---- -// macOS caches an app's icon per bundle path (LaunchServices + iconservicesd). -// The in-app updater swaps the bundle in place at the same path and identifier, -// so without a nudge the Dock/Finder keep showing the icon cached for the old -// build. We record the version that last launched and, when it changes, ask -// macOS to re-read the bundle once. - -/// True when the icon cache should be refreshed: the recorded last-launched -/// version is missing (pre-marker install or first launch) or differs from the -/// running version. Pure so it can be unit-tested without a real bundle. -fn icon_refresh_needed(previous: Option<&str>, current: &str) -> bool { - previous != Some(current) -} - -/// Record the running version next to the database and, when it changed since -/// the last launch, refresh the macOS icon cache. A no-op on the happy path -/// (same version) and in dev builds (no `.app` bundle). -fn refresh_icon_cache_if_updated(data_dir: &std::path::Path) { - let current = env!("CARGO_PKG_VERSION"); - let marker = data_dir.join(".last_version"); - let stored = std::fs::read_to_string(&marker).ok(); - let previous = stored.as_deref().map(str::trim); - if !icon_refresh_needed(previous, current) { - return; - } - let _ = std::fs::write(&marker, current); - #[cfg(target_os = "macos")] - if let Some(bundle) = current_app_bundle() { - refresh_macos_icon(bundle); - } -} - -/// Path to the running `.app` bundle, or `None` in a dev build where the -/// executable is not inside a `*.app/Contents/MacOS/` layout. -#[cfg(target_os = "macos")] -fn current_app_bundle() -> Option { - let exe = std::env::current_exe().ok()?; - let bundle = exe.parent()?.parent()?.parent()?; // MacOS -> Contents -> .app - if bundle.extension()?.to_str()? == "app" { - Some(bundle.to_path_buf()) - } else { - None - } -} - -/// Nudge macOS to re-read the bundle's icon: re-register with LaunchServices, -/// bump the bundle mtime (part of the icon cache key), then relaunch the Dock. -/// Runs off the main thread; every step is best-effort. -#[cfg(target_os = "macos")] -fn refresh_macos_icon(bundle: std::path::PathBuf) { - std::thread::spawn(move || { - const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; - let _ = std::process::Command::new(LSREGISTER) - .arg("-f") - .arg(&bundle) - .status(); - let _ = std::process::Command::new("touch").arg(&bundle).status(); - let _ = std::process::Command::new("killall").arg("Dock").status(); - }); -} - -// ---- window helpers ---- - -fn show_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - let _ = w.center(); - let _ = w.show(); - let _ = w.set_focus(); - // Frontend focuses the textarea and restores any preserved draft. - let _ = w.emit("capture:shown", ()); - } -} - -fn hide_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - let _ = w.hide(); - } -} - -fn toggle_capture_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("capture") { - if w.is_visible().unwrap_or(false) { - hide_capture_window(app); - } else { - show_capture_window(app); - } - } -} - -fn show_library_window(app: &AppHandle) { - if let Some(w) = app.get_webview_window("library") { - let _ = w.show(); - let _ = w.set_focus(); - } -} - -// ---- utility commands ---- - -#[tauri::command] -fn open_url(app: AppHandle, url: String) { - let _ = app.opener().open_url(&url, None::<&str>); -} +mod commands; +mod shell; +use commands::{notes::*, settings::*, tags::*, workspaces::*}; +use shell::{capture::*, files::*, quit::*, windows::*}; // ---- app shell ---- @@ -596,11 +120,26 @@ pub fn run() { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; } - let store = Store::open(&db_path) - .map_err(|e| format!("cannot open store: {e}"))?; + let (store, recovered) = + Store::open_or_recover(&db_path).map_err(|e| format!("cannot open store: {e}"))?; app.manage(AppState { store: Mutex::new(store), }); + app.manage(CaptureMetrics::default()); + if recovered { + // Non-blocking on purpose: setup must finish (single-instance + // handshake, window creation) whether or not the user has + // acknowledged the dialog. + app.dialog() + .message( + "Your notes library could not be read, so a fresh one was \ + started. The unreadable file was kept next to it with a \ + \".corrupt\" suffix in case its contents can be recovered.", + ) + .title("Library recovered") + .kind(MessageDialogKind::Warning) + .show(|_| {}); + } // After an in-place update, refresh the cached app icon once. refresh_icon_cache_if_updated(&dir); @@ -610,13 +149,13 @@ pub fn run() { // (Services, Hide, Hide Others) is a macOS convention with no // Windows/Linux equivalent, so off macOS its Settings and Quit // entries live in the File submenu instead. - let settings_item = MenuItem::with_id( - app, - "settings", - "Settings…", - true, - Some("CmdOrCtrl+,"), - )?; + let settings_item = + MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+,"))?; + // Custom Quit instead of PredefinedMenuItem::quit(): the predefined + // item exits the process directly, skipping the flush handshake, so + // ⌘Q would drop the tail of whatever was being typed. + let quit_item = + MenuItem::with_id(app, "quit", "Quit InstantNotes", true, Some("CmdOrCtrl+Q"))?; #[cfg(target_os = "macos")] let app_submenu = SubmenuBuilder::new(app, "InstantNotes") .about(None) @@ -629,22 +168,12 @@ pub fn run() { .hide_others() .show_all() .separator() - .quit() + .item(&quit_item) .build()?; - let new_note_item = MenuItem::with_id( - app, - "new_note", - "New Note", - true, - Some("CmdOrCtrl+N"), - )?; - let export_item = MenuItem::with_id( - app, - "export_note", - "Export Note As…", - true, - None::<&str>, - )?; + let new_note_item = + MenuItem::with_id(app, "new_note", "New Note", true, Some("CmdOrCtrl+N"))?; + let export_item = + MenuItem::with_id(app, "export_note", "Export Note As…", true, None::<&str>)?; let file_submenu = { let builder = SubmenuBuilder::new(app, "File") .item(&new_note_item) @@ -655,7 +184,7 @@ pub fn run() { .separator() .item(&settings_item) .separator() - .quit(); + .item(&quit_item); builder.build()? }; let edit_submenu = SubmenuBuilder::new(app, "Edit") @@ -689,6 +218,7 @@ pub fn run() { show_library_window(app); let _ = app.emit("menu:export-note", ()); } + "quit" => request_quit(app), _ => {} }); @@ -708,10 +238,14 @@ pub fn run() { let open_library_item = MenuItem::with_id(app, "open_library", "Open Library", true, None::<&str>)?; - let about = - PredefinedMenuItem::about(app, Some("About InstantNotes"), None)?; - let check_updates = - MenuItem::with_id(app, "check_updates", "Check for Updates…", true, None::<&str>)?; + let about = PredefinedMenuItem::about(app, Some("About InstantNotes"), None)?; + let check_updates = MenuItem::with_id( + app, + "check_updates", + "Check for Updates…", + true, + None::<&str>, + )?; let repo = MenuItem::with_id(app, "open_repo", "Repository on GitHub", true, None::<&str>)?; let data_folder = @@ -760,7 +294,9 @@ pub fn run() { let _ = app.opener().open_url(REPO_URL, None::<&str>); } "open_data_dir" => open_data_folder(app), - "quit" => app.exit(0), + // Through the flush handshake, never a direct exit; see + // the quit handshake section. + "quit" => request_quit(app), _ => {} }) .build(app)?; @@ -785,10 +321,31 @@ pub fn run() { } else { Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space) }; - if let Err(e) = app.global_shortcut().register(shortcut) { - // Content-free log per SEC-001; conflict fallback UI is an M4 item. + // Human-readable label for the conflict notice; mirrors the + // registration matrix above and captureShortcut in platform.ts. + let shortcut_label = if cfg!(target_os = "macos") { + if cfg!(debug_assertions) { + "⌥⇧Space" + } else { + "⌥Space" + } + } else if cfg!(debug_assertions) { + "Ctrl+Shift+Alt+Space" + } else { + "Ctrl+Shift+Space" + }; + let shortcut_failure = app.global_shortcut().register(shortcut).err().map(|e| { + // Content-free log per SEC-001; the welcome screen surfaces + // the conflict to the user. eprintln!("global shortcut registration failed: {e}"); + shortcut_label.to_string() + }); + if let Some(label) = &shortcut_failure { + let _ = app.emit("shortcut:failed", label.clone()); } + app.manage(ShortcutStatus { + failed: shortcut_failure, + }); if let Some(library) = app.get_webview_window("library") { #[cfg(not(debug_assertions))] @@ -826,6 +383,10 @@ pub fn run() { permanently_delete_note, list_notes, search_notes, + set_notes_flags, + soft_delete_notes, + restore_notes, + destroy_notes, list_tags, get_or_create_tag, update_tag, @@ -837,6 +398,7 @@ pub fn run() { get_or_create_workspace, rename_workspace, delete_workspace, + list_workspace_tags, add_note_to_workspace, remove_note_from_workspace, workspaces_for_note, @@ -850,53 +412,34 @@ pub fn run() { export_theme_file, import_theme_file, export_note_file, - open_url + save_attachment, + get_attachments_dir, + open_url, + quit_app, + capture_input_ready, + get_capture_latency, + get_shortcut_failure ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} - -#[cfg(test)] -mod tests { - use super::{export_theme_file, icon_refresh_needed, import_theme_file}; - - #[test] - fn icon_refresh_when_version_changed_or_unknown() { - // First launch / upgrade from a build that never wrote the marker. - assert!(icon_refresh_needed(None, "0.5.3")); - // In-place update from an older recorded version. - assert!(icon_refresh_needed(Some("0.5.2"), "0.5.3")); - // Same version relaunch: nothing to refresh. - assert!(!icon_refresh_needed(Some("0.5.3"), "0.5.3")); - } - - #[test] - fn theme_file_round_trip() { - let dir = std::env::temp_dir(); - let path = dir.join(format!("instantnotes-theme-{}.intheme.json", std::process::id())); - let path_str = path.to_string_lossy().to_string(); - let json = r#"{"id":"x","name":"X","version":1}"#.to_string(); - - export_theme_file(path_str.clone(), json.clone()).expect("write"); - let read_back = import_theme_file(path_str.clone()).expect("read"); - assert_eq!(read_back, json); - - let _ = std::fs::remove_file(&path); - } - - #[test] - fn import_missing_file_errors() { - let res = import_theme_file("/nonexistent/path/theme.intheme.json".into()); - assert!(res.is_err()); - assert_eq!(res.unwrap_err().code, "STORAGE_ERROR"); - } - - #[test] - fn theme_path_validation_rejects_non_absolute_and_non_json() { - // Relative path → rejected before any filesystem access. - assert!(export_theme_file("relative/theme.json".into(), "{}".into()).is_err()); - assert!(import_theme_file("relative/theme.json".into()).is_err()); - // Absolute but not a .json file → rejected. - assert!(import_theme_file("/tmp/not-a-theme.txt".into()).is_err()); - } + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + // The updater's relaunch (request_restart) drives this exit + // with RESTART_EXIT_CODE and latches restart-on-exit inside + // Tauri. Preventing it would leave that latch set with no + // exit coming, stranding the freshly installed update, so + // the restart passes through untouched; updater.restart() + // flushes pending edits before it ever calls relaunch. + if code == Some(tauri::RESTART_EXIT_CODE) { + return; + } + // Exit paths that bypass the menu and tray (macOS Dock quit): + // hold the exit, run the same flush handshake, and rely on + // the same dead-webview fallback. + if !QUIT_READY.load(Ordering::Acquire) { + api.prevent_exit(); + request_quit(app); + } + } + }); } diff --git a/src-tauri/src/shell/capture.rs b/src-tauri/src/shell/capture.rs new file mode 100644 index 0000000..5d528d5 --- /dev/null +++ b/src-tauri/src/shell/capture.rs @@ -0,0 +1,112 @@ +//! Capture reveal-to-ready latency: a rolling median surfaced in the About +//! panel. "Capture is discharge" only holds if the panel is ready before the +//! thought decays, so reveal-to-input-ready is tracked as a first-class number. +//! The anchor is the moment the shell starts revealing the window (the earliest +//! point we control; the OS delivers no hotkey-press timestamp). Note content is +//! never involved here. + +use crate::*; + +/// Rolling window; enough for a stable median, small enough to forget history. +const CAPTURE_SAMPLE_CAP: usize = 50; + +#[derive(Default)] +pub(crate) struct CaptureMetrics { + inner: Mutex, +} + +#[derive(Default)] +struct CaptureMetricsInner { + shown_at: Option, + samples_ms: Vec, +} + +impl CaptureMetrics { + /// Stamp the reveal start; the next capture_input_ready measures against it. + pub(crate) fn mark_shown(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.shown_at = Some(std::time::Instant::now()); + } + } +} + +#[derive(Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureLatencySummary { + last_ms: Option, + median_ms: Option, + samples: usize, +} + +fn push_capture_sample(samples: &mut Vec, ms: u64) { + samples.push(ms); + if samples.len() > CAPTURE_SAMPLE_CAP { + samples.remove(0); + } +} + +fn median_ms(samples: &[u64]) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + Some(sorted[sorted.len() / 2]) +} + +/// Called by the capture webview once its textarea has focus after a +/// reveal (post-paint). Consumes the pending stamp so a stray call can +/// never double-record; returns the measured reveal-to-ready milliseconds. +#[tauri::command] +pub fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult> { + let mut inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + let Some(shown) = inner.shown_at.take() else { + return Ok(None); + }; + let ms = shown.elapsed().as_millis() as u64; + push_capture_sample(&mut inner.samples_ms, ms); + Ok(Some(ms)) +} + +#[tauri::command] +pub fn get_capture_latency(metrics: State<'_, CaptureMetrics>) -> CmdResult { + let inner = metrics.inner.lock().map_err(|_| CmdError { + code: "STORAGE_ERROR".into(), + message: "internal state lock poisoned".into(), + })?; + Ok(CaptureLatencySummary { + last_ms: inner.samples_ms.last().copied(), + median_ms: median_ms(&inner.samples_ms), + samples: inner.samples_ms.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::{median_ms, push_capture_sample, CAPTURE_SAMPLE_CAP}; + + #[test] + fn capture_samples_roll_over_at_the_cap() { + let mut samples = Vec::new(); + for ms in 0..(CAPTURE_SAMPLE_CAP as u64 + 10) { + push_capture_sample(&mut samples, ms); + } + assert_eq!(samples.len(), CAPTURE_SAMPLE_CAP); + // Oldest entries were evicted; the newest survives. + assert_eq!(samples.first().copied(), Some(10)); + assert_eq!(samples.last().copied(), Some(CAPTURE_SAMPLE_CAP as u64 + 9)); + } + + #[test] + fn median_is_none_when_empty_and_stable_against_outliers() { + assert_eq!(median_ms(&[]), None); + assert_eq!(median_ms(&[40]), Some(40)); + // One slow cold start must not drag the reported number. + assert_eq!(median_ms(&[35, 38, 40, 42, 900]), Some(40)); + // Input order is irrelevant. + assert_eq!(median_ms(&[900, 40, 35, 42, 38]), Some(40)); + } +} diff --git a/src-tauri/src/shell/files.rs b/src-tauri/src/shell/files.rs new file mode 100644 index 0000000..9a02c7f --- /dev/null +++ b/src-tauri/src/shell/files.rs @@ -0,0 +1,183 @@ +//! Path-validated byte I/O for paths the user picks through native dialogs: +//! portable `.intheme.json` themes, note export, and pasted/dropped image +//! attachments. The dialogs run in JS; Rust only reads/writes the chosen path, +//! so no broad filesystem capability is needed. + +use crate::*; + +/// Reject anything that isn't an absolute path to a `.json` file. The path is +/// chosen by the user through a native save/open dialog but arrives here from the +/// webview, so this guard keeps the command from becoming a way to read or write +/// arbitrary files anywhere on disk. +fn validate_theme_path(path: &str) -> CmdResult<()> { + let p = std::path::Path::new(path); + if !p.is_absolute() { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "theme path must be absolute".into(), + }); + } + let is_json = p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("json")); + if !is_json { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "theme file must have a .json extension".into(), + }); + } + Ok(()) +} + +#[tauri::command(async)] +pub fn export_theme_file(path: String, contents: String) -> CmdResult<()> { + validate_theme_path(&path)?; + std::fs::write(&path, contents).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write theme file: {e}"), + }) +} + +#[tauri::command(async)] +pub fn import_theme_file(path: String) -> CmdResult { + validate_theme_path(&path)?; + std::fs::read_to_string(&path).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not read theme file: {e}"), + }) +} + +fn validate_export_path(path: &str) -> CmdResult<()> { + let p = std::path::Path::new(path); + if !p.is_absolute() { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "export path must be absolute".into(), + }); + } + let is_allowed = p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt")); + if !is_allowed { + return Err(CmdError { + code: "STORAGE_ERROR".into(), + message: "export file must have a .md or .txt extension".into(), + }); + } + Ok(()) +} + +#[tauri::command(async)] +pub fn export_note_file(path: String, contents: String) -> CmdResult<()> { + validate_export_path(&path)?; + std::fs::write(&path, contents).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write export file: {e}"), + }) +} + +// Pasted/dropped images live as files under /attachments and notes +// reference them by relative `attachments/` markdown paths, so exported +// markdown stays portable and the DB stays lean. The webview reads them back +// through the asset protocol (scoped to this directory in tauri.conf.json). + +const ATTACHMENT_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; + +fn attachments_dir(app: &AppHandle) -> CmdResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("no app data dir: {e}"), + })? + .join("attachments"); + std::fs::create_dir_all(&dir).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not create attachments dir: {e}"), + })?; + Ok(dir) +} + +#[tauri::command(async)] +pub fn get_attachments_dir(app: AppHandle) -> CmdResult { + Ok(attachments_dir(&app)?.to_string_lossy().into_owned()) +} + +/// Store one image. The body is the raw bytes (not JSON) so a screenshot paste +/// doesn't pay for number-array serialization; the extension rides in a header. +/// Returns the generated filename; the caller builds `attachments/`. +#[tauri::command(async)] +pub fn save_attachment(app: AppHandle, request: tauri::ipc::Request<'_>) -> CmdResult { + let ext = request + .headers() + .get("x-attachment-ext") + .and_then(|v| v.to_str().ok()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !ATTACHMENT_EXTS.contains(&ext.as_str()) { + return Err(CmdError { + code: "VALIDATION".into(), + message: format!("unsupported attachment type: {ext:?}"), + }); + } + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err(CmdError { + code: "VALIDATION".into(), + message: "attachment body must be raw bytes".into(), + }); + }; + if bytes.is_empty() { + return Err(CmdError { + code: "VALIDATION".into(), + message: "attachment is empty".into(), + }); + } + let name = format!("{}.{ext}", uuid::Uuid::new_v4()); + let path = attachments_dir(&app)?.join(&name); + std::fs::write(&path, bytes).map_err(|e| CmdError { + code: "STORAGE_ERROR".into(), + message: format!("could not write attachment: {e}"), + })?; + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::{export_theme_file, import_theme_file}; + + #[test] + fn theme_file_round_trip() { + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "instantnotes-theme-{}.intheme.json", + std::process::id() + )); + let path_str = path.to_string_lossy().to_string(); + let json = r#"{"id":"x","name":"X","version":1}"#.to_string(); + + export_theme_file(path_str.clone(), json.clone()).expect("write"); + let read_back = import_theme_file(path_str.clone()).expect("read"); + assert_eq!(read_back, json); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn import_missing_file_errors() { + let res = import_theme_file("/nonexistent/path/theme.intheme.json".into()); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().code, "STORAGE_ERROR"); + } + + #[test] + fn theme_path_validation_rejects_non_absolute_and_non_json() { + // Relative path -> rejected before any filesystem access. + assert!(export_theme_file("relative/theme.json".into(), "{}".into()).is_err()); + assert!(import_theme_file("relative/theme.json".into()).is_err()); + // Absolute but not a .json file -> rejected. + assert!(import_theme_file("/tmp/not-a-theme.txt".into()).is_err()); + } +} diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs new file mode 100644 index 0000000..0a0544d --- /dev/null +++ b/src-tauri/src/shell/mod.rs @@ -0,0 +1,8 @@ +//! The desktop shell behind run(): capture-latency metrics, window management, +//! file I/O commands, and the quit handshake. run() keeps only the native menu, +//! tray, and setup wiring that composes these. + +pub(crate) mod capture; +pub(crate) mod files; +pub(crate) mod quit; +pub(crate) mod windows; diff --git a/src-tauri/src/shell/quit.rs b/src-tauri/src/shell/quit.rs new file mode 100644 index 0000000..1815fd8 --- /dev/null +++ b/src-tauri/src/shell/quit.rs @@ -0,0 +1,43 @@ +//! The quit handshake. Body edits are debounced in the webview, so exiting the +//! process directly would drop the tail of whatever was just typed. Every quit +//! path (menu, tray, Dock) emits "app:quit-requested"; the library window +//! flushes its pending edits and answers with quit_app, which really exits. + +use crate::*; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// True once the frontend flushed and called quit_app, or once the fallback +/// gave up waiting. ExitRequested lets the exit proceed only when this is set, +/// so the flush handshake runs at most once per quit. +pub(crate) static QUIT_READY: AtomicBool = AtomicBool::new(false); + +/// How long a quit waits for the webview flush before exiting anyway. +const QUIT_FLUSH_GRACE_MS: u64 = 800; + +/// Ask the webviews to flush, then exit. The fallback timer exists because +/// quit must not block forever on a dead webview: if the frontend never +/// answers with quit_app, exit anyway after the grace period. +pub(crate) fn request_quit(app: &AppHandle) { + let _ = app.emit("app:quit-requested", ()); + let handle = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(QUIT_FLUSH_GRACE_MS)); + // swap keeps the fallback and quit_app from racing: whichever runs + // first marks the handshake done and the other becomes a no-op. + if !QUIT_READY.swap(true, Ordering::AcqRel) { + handle.exit(0); + } + }); +} + +/// Final leg of the handshake: the library webview has flushed pending edits. +#[tauri::command] +pub fn quit_app(app: AppHandle) { + QUIT_READY.store(true, Ordering::Release); + app.exit(0); +} + +#[tauri::command] +pub fn get_shortcut_failure(state: State<'_, ShortcutStatus>) -> Option { + state.failed.clone() +} diff --git a/src-tauri/src/shell/windows.rs b/src-tauri/src/shell/windows.rs new file mode 100644 index 0000000..816c102 --- /dev/null +++ b/src-tauri/src/shell/windows.rs @@ -0,0 +1,202 @@ +//! Library and capture window show/hide, native macOS vibrancy and theme, the +//! external-link opener, and the icon-cache refresh the in-place updater needs. + +use crate::shell::capture::CaptureMetrics; +use crate::*; + +#[tauri::command] +pub fn hide_capture(app: AppHandle) { + hide_capture_window(&app); +} + +#[tauri::command] +pub fn open_library(app: AppHandle) { + show_library_window(&app); +} + +/// Apply a native macOS vibrancy material to the library window, or clear it when +/// `material` is None/unknown. Vibrancy is the closest a webview app gets to the +/// Tahoe "Liquid Glass" look; it requires the always-transparent window and a +/// translucent surface above it (the theme's sidebar token). A no-op off macOS. +#[tauri::command] +pub fn set_window_vibrancy(app: AppHandle, material: Option) { + #[cfg(target_os = "macos")] + { + use window_vibrancy::{apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial}; + let Some(win) = app.get_webview_window("library") else { + return; + }; + // Any known material applies; None or an unknown string clears. + let chosen = material.as_deref().and_then(|m| match m { + "sidebar" => Some(NSVisualEffectMaterial::Sidebar), + "under-window" => Some(NSVisualEffectMaterial::UnderWindowBackground), + "header" => Some(NSVisualEffectMaterial::HeaderView), + "menu" => Some(NSVisualEffectMaterial::Menu), + "popover" => Some(NSVisualEffectMaterial::Popover), + "hud" => Some(NSVisualEffectMaterial::HudWindow), + _ => None, + }); + match chosen { + Some(m) => { + let _ = apply_vibrancy(&win, m, None, None); + } + None => { + let _ = clear_vibrancy(&win); + } + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, material); + } +} + +/// Match the native library window's theme (titlebar and traffic-light treatment) +/// to the in-app light/dark variant. Tauri maps this to the window's OS appearance, +/// so the chrome follows the active theme instead of the launch-time system setting. +/// An unknown variant is a no-op; the borderless capture window has no native chrome +/// and is left alone. +#[tauri::command] +pub fn set_window_theme(app: AppHandle, variant: String) { + use tauri::Theme; + let theme = match variant.as_str() { + "light" => Theme::Light, + "dark" => Theme::Dark, + _ => return, + }; + if let Some(win) = app.get_webview_window("library") { + let _ = win.set_theme(Some(theme)); + } +} + +#[tauri::command] +pub fn open_url(app: AppHandle, url: String) { + let _ = app.opener().open_url(&url, None::<&str>); +} + +// ---- window helpers ---- + +pub(crate) fn show_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + // Stamp before any window work so the sample covers the whole reveal. + if let Some(metrics) = app.try_state::() { + metrics.mark_shown(); + } + let _ = w.center(); + let _ = w.show(); + let _ = w.set_focus(); + // Frontend focuses the textarea and restores any preserved draft. + let _ = w.emit("capture:shown", ()); + } +} + +pub(crate) fn hide_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + let _ = w.hide(); + } +} + +pub(crate) fn toggle_capture_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("capture") { + if w.is_visible().unwrap_or(false) { + hide_capture_window(app); + } else { + show_capture_window(app); + } + } +} + +pub(crate) fn show_library_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("library") { + let _ = w.show(); + let _ = w.set_focus(); + } +} + +// ---- data folder + icon cache refresh ---- + +pub(crate) const REPO_URL: &str = "https://github.com/Jam-Sw/InstantNotes"; + +pub(crate) fn open_data_folder(app: &AppHandle) { + if let Ok(dir) = app.path().app_data_dir() { + // Via the opener plugin rather than a raw `open` subprocess, so it stays + // on Tauri's permission-checked path. Called only from Rust with our own + // data directory - never a webview-supplied path. + let _ = app.opener().open_path(dir.to_string_lossy(), None::<&str>); + } +} + +// macOS caches an app's icon per bundle path (LaunchServices + iconservicesd). +// The in-app updater swaps the bundle in place at the same path and identifier, +// so without a nudge the Dock/Finder keep showing the icon cached for the old +// build. We record the version that last launched and, when it changes, ask +// macOS to re-read the bundle once. + +/// True when the icon cache should be refreshed: the recorded last-launched +/// version is missing (pre-marker install or first launch) or differs from the +/// running version. Pure so it can be unit-tested without a real bundle. +fn icon_refresh_needed(previous: Option<&str>, current: &str) -> bool { + previous != Some(current) +} + +/// Record the running version next to the database and, when it changed since +/// the last launch, refresh the macOS icon cache. A no-op on the happy path +/// (same version) and in dev builds (no `.app` bundle). +pub(crate) fn refresh_icon_cache_if_updated(data_dir: &std::path::Path) { + let current = env!("CARGO_PKG_VERSION"); + let marker = data_dir.join(".last_version"); + let stored = std::fs::read_to_string(&marker).ok(); + let previous = stored.as_deref().map(str::trim); + if !icon_refresh_needed(previous, current) { + return; + } + let _ = std::fs::write(&marker, current); + #[cfg(target_os = "macos")] + if let Some(bundle) = current_app_bundle() { + refresh_macos_icon(bundle); + } +} + +/// Path to the running `.app` bundle, or `None` in a dev build where the +/// executable is not inside a `*.app/Contents/MacOS/` layout. +#[cfg(target_os = "macos")] +fn current_app_bundle() -> Option { + let exe = std::env::current_exe().ok()?; + let bundle = exe.parent()?.parent()?.parent()?; // MacOS -> Contents -> .app + if bundle.extension()?.to_str()? == "app" { + Some(bundle.to_path_buf()) + } else { + None + } +} + +/// Nudge macOS to re-read the bundle's icon: re-register with LaunchServices, +/// bump the bundle mtime (part of the icon cache key), then relaunch the Dock. +/// Runs off the main thread; every step is best-effort. +#[cfg(target_os = "macos")] +fn refresh_macos_icon(bundle: std::path::PathBuf) { + std::thread::spawn(move || { + const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + let _ = std::process::Command::new(LSREGISTER) + .arg("-f") + .arg(&bundle) + .status(); + let _ = std::process::Command::new("touch").arg(&bundle).status(); + let _ = std::process::Command::new("killall").arg("Dock").status(); + }); +} + +#[cfg(test)] +mod tests { + use super::icon_refresh_needed; + + #[test] + fn icon_refresh_when_version_changed_or_unknown() { + // First launch / upgrade from a build that never wrote the marker. + assert!(icon_refresh_needed(None, "0.5.3")); + // In-place update from an older recorded version. + assert!(icon_refresh_needed(Some("0.5.2"), "0.5.3")); + // Same version relaunch: nothing to refresh. + assert!(!icon_refresh_needed(Some("0.5.3"), "0.5.3")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 316583e..4d8ff1e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "InstantNotes", - "version": "0.7.0", + "version": "0.8.0", "identifier": "com.instantnotes.app", "build": { "beforeDevCommand": "npm run dev", @@ -41,7 +41,11 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost" + "csp": "default-src 'self'; img-src 'self' data: asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost", + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/attachments/**"] + } } }, "bundle": { diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts index b368bde..f6ec4fa 100644 --- a/src/lib/api/client.test.ts +++ b/src/lib/api/client.test.ts @@ -12,6 +12,7 @@ import { ApiError, getOrCreateWorkspace, listWorkspaces, + listWorkspaceTags, removeNoteFromWorkspace, renameWorkspace, deleteWorkspace, @@ -46,12 +47,20 @@ describe("workspace client wrappers", () => { }); }); - it("deleteWorkspace passes the id", async () => { - invoke.mockResolvedValue(undefined); - await deleteWorkspace("w1"); + it("deleteWorkspace passes the id and returns the member note ids", async () => { + invoke.mockResolvedValue(["n1", "n2"]); + await expect(deleteWorkspace("w1")).resolves.toEqual(["n1", "n2"]); expect(invoke).toHaveBeenCalledWith("delete_workspace", { id: "w1" }); }); + it("listWorkspaceTags passes the workspaceId", async () => { + invoke.mockResolvedValue([]); + await expect(listWorkspaceTags("w1")).resolves.toEqual([]); + expect(invoke).toHaveBeenCalledWith("list_workspace_tags", { + workspaceId: "w1", + }); + }); + it("membership wrappers pass noteId and workspaceId", async () => { invoke.mockResolvedValue(undefined); await addNoteToWorkspace("n1", "w1"); @@ -69,7 +78,9 @@ describe("workspace client wrappers", () => { it("workspacesForNote passes the noteId", async () => { invoke.mockResolvedValue([]); await workspacesForNote("n1"); - expect(invoke).toHaveBeenCalledWith("workspaces_for_note", { noteId: "n1" }); + expect(invoke).toHaveBeenCalledWith("workspaces_for_note", { + noteId: "n1", + }); }); it("maps structured backend errors to ApiError", async () => { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 8abc1cd..0856bf8 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { + CaptureLatencySummary, CreateNoteInput, Note, NoteFilter, @@ -23,7 +24,10 @@ export class ApiError extends Error { } } -async function call(cmd: string, args?: Record): Promise { +async function call( + cmd: string, + args?: Record, +): Promise { try { return await invoke(cmd, args); } catch (e) { @@ -51,6 +55,23 @@ export const listNotes = (filter: NoteFilter = {}) => export const searchNotes = (text: string, limit = 50) => call("search_notes", { text, limit }); +// Bulk variants: one transaction and one change event for a whole multi-select. +export const setNotesFlags = ( + ids: string[], + flags: { isPinned?: boolean; isArchived?: boolean }, +) => + call("set_notes_flags", { + ids, + isPinned: flags.isPinned ?? null, + isArchived: flags.isArchived ?? null, + }); +export const softDeleteNotes = (ids: string[]) => + call("soft_delete_notes", { ids }); +export const restoreNotes = (ids: string[]) => + call("restore_notes", { ids }); +export const destroyNotes = (ids: string[], confirm: boolean) => + call("destroy_notes", { ids, confirm }); + // ---- tags ---- export const listTags = () => call("list_tags"); export const getOrCreateTag = (name: string) => @@ -65,15 +86,27 @@ export const removeTagFromNote = (noteId: string, tagId: string) => export const tagsForNote = (noteId: string) => call("tags_for_note", { noteId }); -// ---- workspaces ---- +// ---- workspaces (the UI calls these "Spaces") ---- +// GLOSSARY / naming boundary: the product term is "Space" everywhere the user +// sees it (sidebar, copy, component names); the command strings, storage +// tables, and these wrapper names keep "workspace". This file is the single +// place the two vocabularies meet, by decision: renaming the storage internals +// is churn with no user value (see openspec/project.md and +// docs/superpowers/specs/2026-07-10-spaces-design.md). One concept, two names, +// documented here so no layer has to guess which it is in. export const listWorkspaces = () => call("list_workspaces"); export const getOrCreateWorkspace = (name: string) => call("get_or_create_workspace", { name }); export const renameWorkspace = (id: string, name: string) => call("rename_workspace", { id, name }); +// Returns the member note ids (including archived and trashed members) so +// the caller can offer an undo that restores every membership. export const deleteWorkspace = (id: string) => - call("delete_workspace", { id }); + call("delete_workspace", { id }); +// Tags on the workspace's visible notes, counts scoped to the workspace. +export const listWorkspaceTags = (workspaceId: string) => + call("list_workspace_tags", { workspaceId }); export const addNoteToWorkspace = (noteId: string, workspaceId: string) => call("add_note_to_workspace", { noteId, workspaceId }); export const removeNoteFromWorkspace = (noteId: string, workspaceId: string) => @@ -81,6 +114,14 @@ export const removeNoteFromWorkspace = (noteId: string, workspaceId: string) => export const workspacesForNote = (noteId: string) => call("workspaces_for_note", { noteId }); +// ---- capture latency ---- +// Reports that the capture textarea is focused and painted; the backend +// turns the pending reveal stamp into one latency sample. +export const captureInputReady = () => + call("capture_input_ready"); +export const getCaptureLatency = () => + call("get_capture_latency"); + // ---- settings ---- export const getSetting = (key: string) => call("get_setting", { key }); @@ -112,3 +153,33 @@ export const importThemeFile = (path: string) => // ---- note export ---- export const exportNoteFile = (path: string, contents: string) => call("export_note_file", { path, contents }); + +// ---- attachments ---- +// Raw-body invoke: image bytes go over IPC as-is (no JSON number array), with +// the extension in a header. Returns the stored filename; notes reference it +// as `attachments/`. +export const saveAttachment = async ( + bytes: Uint8Array, + ext: string, +): Promise => { + try { + return await invoke("save_attachment", bytes, { + headers: { "x-attachment-ext": ext }, + }); + } catch (e) { + if (e && typeof e === "object" && "code" in e && "message" in e) { + throw new ApiError(String(e.code), String(e.message)); + } + throw new ApiError("STORAGE_ERROR", String(e)); + } +}; +export const getAttachmentsDir = () => call("get_attachments_dir"); + +// ---- app lifecycle ---- +// Answer to "app:quit-requested": pending edits are flushed, exit for real now. +export const quitApp = () => call("quit_app"); +// Label of the capture shortcut when startup registration failed, else null. +// A command rather than an event alone: the failure happens before the library +// webview has listeners attached, so an event would be lost. +export const getShortcutFailure = () => + call("get_shortcut_failure"); diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 7d6dea4..a800656 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,13 +1,6 @@ // IPC contract types shared by the Svelte UI and the Rust desktop layer. // Field names are camelCase over the wire (serde). -export type SyncState = - | "local_only" - | "pending_sync" - | "synced" - | "conflict" - | "sync_error"; - export interface Note { id: string; title: string; @@ -19,9 +12,6 @@ export interface Note { isArchived: boolean; isDeleted: boolean; deletedAt?: string | null; - syncState: SyncState; - version: number; - lastSyncedAt?: string | null; } export interface Tag { @@ -67,6 +57,10 @@ export interface NoteFilter { isPinned?: boolean; isArchived?: boolean; isDeleted?: boolean; + /** Only notes never opened in the library (capture-born, untriaged). */ + neverOpened?: boolean; + /** Only notes created strictly before this ISO-8601 timestamp. */ + createdBefore?: string; sortBy?: "updatedAt" | "createdAt" | "lastOpenedAt" | "title"; sortOrder?: "asc" | "desc"; limit?: number; @@ -85,3 +79,10 @@ export interface AppErrorPayload { code: string; message: string; } + +/** Reveal-to-input-ready timing for the capture panel (no note content). */ +export interface CaptureLatencySummary { + lastMs: number | null; + medianMs: number | null; + samples: number; +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 90b0887..a9a6bcb 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -4,6 +4,7 @@ // of its own. Pure filtering/ranking lives in command-filter.ts. import { library } from "$lib/stores/library.svelte"; +import { sidebar } from "$lib/stores/sidebar.svelte"; import { theme } from "$lib/stores/theme.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; import { exportTheme, importTheme } from "$lib/themes/share"; @@ -63,6 +64,13 @@ export function buildThemeCommands(): Command[] { export function buildCommands(): Command[] { const commands: Command[] = [ { id: "note.new", title: "New note", group: "Notes", shortcut: `${modKey}N`, run: () => library.newNote() }, + { + id: "view.sidebar", + title: sidebar.collapsed ? "Show sidebar" : "Hide sidebar", + group: "View", + shortcut: `${modKey}\\`, + run: () => sidebar.toggle(), + }, ]; if (library.selected) { diff --git a/src/lib/components/BulkActions.svelte b/src/lib/components/BulkActions.svelte index f29b174..79e9119 100644 --- a/src/lib/components/BulkActions.svelte +++ b/src/lib/components/BulkActions.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 82d797c..1b95d33 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -1,7 +1,10 @@ + +{#if confirmDialog.request} + {@const req = confirmDialog.request} + +{/if} + + diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte new file mode 100644 index 0000000..59e9bd0 --- /dev/null +++ b/src/lib/components/ContextMenu.svelte @@ -0,0 +1,129 @@ + + + + + + + diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index f86f959..bdc3a76 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -2,25 +2,31 @@ // CodeMirror 6 wrapper. CM6 owns its DOM — Svelte never renders inside the // container. One-way discipline: external `value` changes dispatch into CM6 // (guarded against feedback); user edits flow out through `onchange`. + // + // All markdown preview/interaction behavior lives in the editor kernel + // (src/lib/editor, see its ARCHITECTURE.md). This component only wires the + // kernel to the app: Rust APIs in, settings effects in, edits out. import { onMount } from "svelte"; - import { - EditorView, - keymap, - placeholder as cmPlaceholder, - ViewPlugin, - Decoration, - type DecorationSet, - type ViewUpdate, - } from "@codemirror/view"; - import { EditorState, RangeSetBuilder } from "@codemirror/state"; + import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view"; + import { EditorState } from "@codemirror/state"; import { history, defaultKeymap, historyKeymap } from "@codemirror/commands"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; + import { languages } from "@codemirror/language-data"; import { syntaxHighlighting } from "@codemirror/language"; + import { highlightExtension } from "$lib/markdown-extensions"; import { markdownHighlight } from "$lib/markdown-highlight"; import { formatEdit, type FormatKind, type Sel } from "$lib/markdown-format"; import { activeMarks, type ActiveMarks } from "$lib/markdown-active"; - import { wysiwygExtension, wysiwygTheme, setPreviewMode } from "$lib/wysiwyg"; + import { + editorKernel, + setPreviewMode, + setLinkPrefs, + setAttachmentsBase, + } from "$lib/editor"; + import { linkPrefs } from "$lib/stores/links.svelte"; import { listIndentChanges } from "$lib/list-indent"; + import { getAttachmentsDir, openUrl, saveAttachment } from "$lib/api/client"; + import { toasts } from "$lib/stores/toasts.svelte"; let { value = "", @@ -40,38 +46,6 @@ let view: EditorView | undefined; let applyingExternal = false; - const tagMark = Decoration.mark({ class: "cm-tag" }); - const TAG_RE = /(^|\s)(#[\p{L}\p{N}_-]+)/gu; - - function buildTagDecorations(v: EditorView): DecorationSet { - const builder = new RangeSetBuilder(); - for (const { from, to } of v.visibleRanges) { - const text = v.state.doc.sliceString(from, to); - TAG_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = TAG_RE.exec(text))) { - const start = from + m.index + m[1].length; - builder.add(start, start + m[2].length, tagMark); - } - } - return builder.finish(); - } - - const tagHighlighter = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(v: EditorView) { - this.decorations = buildTagDecorations(v); - } - update(u: ViewUpdate) { - if (u.docChanged || u.viewportChanged) { - this.decorations = buildTagDecorations(u.view); - } - } - }, - { decorations: (v) => v.decorations }, - ); - onMount(() => { view = new EditorView({ state: EditorState.create({ @@ -92,16 +66,31 @@ { key: "Tab", run: (v) => applyListIndent(v, false) }, { key: "Shift-Tab", run: (v) => applyListIndent(v, true) }, ]), + // The kernel sits ABOVE defaultKeymap on purpose: it owns Backspace + // (block markers delete as whole objects) and Enter (lists, quotes, + // and tasks continue onto the next line). + editorKernel({ + // Opening goes through Rust (open_url) since the webview has no + // opener capability of its own. + openUrl: (url) => void openUrl(url), + // Paste/drop an image → stored attachment + markdown reference; + // rendered inline in preview once the base dir arrives below. + saveImage: saveAttachment, + onImageError: (m) => toasts.show(`Couldn't save image. ${m}`), + }), keymap.of([...defaultKeymap, ...historyKeymap]), // GFM base so ~~strikethrough~~ parses (the highlight + active-state - // detection both rely on Strikethrough nodes existing). - markdown({ base: markdownLanguage }), + // detection both rely on Strikethrough nodes existing). Fenced code + // gets per-language highlighting; ==highlight== is our own inline + // extension. + markdown({ + base: markdownLanguage, + codeLanguages: languages, + extensions: [highlightExtension], + }), syntaxHighlighting(markdownHighlight), EditorView.lineWrapping, cmPlaceholder(placeholder), - tagHighlighter, - ...wysiwygExtension(), - wysiwygTheme, EditorView.updateListener.of((u) => { if (u.docChanged && !applyingExternal) { onchange?.(u.state.doc.toString()); @@ -118,6 +107,11 @@ }); // Seed the toolbar before the first edit or selection change. onactive?.(activeMarks(view.state)); + // Attachment images can only resolve once Rust reports where they live; + // until then they render as markdown text, then swap in. + void getAttachmentsDir() + .then((dir) => view?.dispatch({ effects: setAttachmentsBase.of(dir) })) + .catch(() => {}); return () => view?.destroy(); }); @@ -141,6 +135,15 @@ } }); + $effect(() => { + // Sync link preferences into CM6; reading the snapshot registers all four + // fields as dependencies so Settings changes apply to open notes live. + const snap = linkPrefs.snapshot(); + if (view) { + view.dispatch({ effects: setLinkPrefs.of(snap) }); + } + }); + export function focus() { view?.focus(); } diff --git a/src/lib/components/FormatToolbar.svelte b/src/lib/components/FormatToolbar.svelte index 86666c0..2f8f226 100644 --- a/src/lib/components/FormatToolbar.svelte +++ b/src/lib/components/FormatToolbar.svelte @@ -60,6 +60,15 @@ > + @@ -94,7 +103,7 @@
@@ -119,8 +128,12 @@ />
- - {#if library.saving}Saving…{:else}Saved · {formatDate(library.selected.updatedAt)}{/if} + + {#if library.saveState === "saving"}Saving…{:else if library.saveState === "failed"}Not saved{:else}Saved · {formatDate(library.selected.updatedAt)}{/if} {#if library.error} {library.error} @@ -176,6 +189,11 @@ color: var(--text-secondary); font-style: italic; } + /* Retries exhausted; the edit stays queued and flushes keep attempting it. */ + .save-state.failed { + color: var(--danger); + font-weight: 500; + } .save-dot { width: 6px; height: 6px; diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 8308bfe..21d6c86 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -2,6 +2,9 @@ import { library, type StatusFilter } from "$lib/stores/library.svelte"; import { formatDate, preview } from "$lib/format"; import { captureShortcut, modKey } from "$lib/platform"; + import { parseHighlightSegments } from "$lib/highlight"; + import { confirmDialog } from "$lib/stores/confirm.svelte"; + import { groupNotes } from "$lib/note-groups"; const statusFilters: { id: StatusFilter; label: string }[] = [ { id: "active", label: "Active" }, @@ -9,6 +12,14 @@ { id: "trash", label: "Trash" }, ]; + // Time-bucketed sections (Pinned / Today / Yesterday / ...). Revisit stays + // flat: it sorts oldest-first by capture date, which time-of-edit buckets + // would fight. "Now" is sampled per list change, matching platform behavior + // (a list left open across midnight regroups on its next change). + const groups = $derived( + library.revisitMode ? null : groupNotes(library.notes, new Date()), + ); + function rowClick(e: MouseEvent, id: string) { if (e.metaKey || e.ctrlKey) { void library.toggleInSelection(id); @@ -20,9 +31,13 @@ } async function confirmEmptyTrash() { - if (window.confirm("Permanently delete all notes in the Trash? This cannot be undone.")) { - await library.emptyTrash(); - } + const ok = await confirmDialog.ask({ + title: "Empty the Trash?", + body: "All notes in Trash will be permanently deleted. This action cannot be undone.", + confirmLabel: "Empty Trash", + tone: "danger", + }); + if (ok) await library.emptyTrash(); } @@ -37,7 +52,23 @@ />
- {#if !library.activeWorkspaceId && !library.activeTagId && !library.searchResults} + {#if library.activeWorkspaceId && !library.searchResults && library.workspaceTags.length > 0} + +
+ {#each library.workspaceTags as tag (tag.id)} + + {/each} +
+ {/if} + {#if !library.activeWorkspaceId && !library.activeTagId && !library.revisitMode && !library.searchResults}
{#each statusFilters as f (f.id)} {:else}
No notes match your search.
{/each} {:else} - {#each library.notes as note (note.id)} + {#snippet noteRow(note: (typeof library.notes)[number])}
{formatDate(note.updatedAt)}
- {:else} + {/snippet} + {#if library.notes.length === 0}
- {#if library.activeWorkspaceId} - No notes in this workspace yet. Open a note and add it here. + {#if library.revisitMode} + All caught up. Every capture has been seen. + {:else if library.activeWorkspaceId && library.scopedTagId} + No notes with this tag in this space. + {:else if library.activeWorkspaceId} + Nothing here yet. New notes land in this space while you're in it. {:else if library.statusFilter === "trash"} Trash is empty. {:else if library.statusFilter === "archived"} @@ -98,7 +134,18 @@ No notes yet. Press {captureShortcut} anywhere to capture your first thought. {/if}
- {/each} + {:else if groups} + {#each groups as group (group.label)} +
{group.label}
+ {#each group.notes as note (note.id)} + {@render noteRow(note)} + {/each} + {/each} + {:else} + {#each library.notes as note (note.id)} + {@render noteRow(note)} + {/each} + {/if} {/if} @@ -144,6 +191,31 @@ padding: 6px 10px; border-bottom: 1px solid var(--border); } + /* Occupies the status-filter's slot: the pills hide inside a space. */ + .space-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + } + .space-tag-chip { + padding: 2px 10px; + border: 1px solid var(--border); + border-radius: 99px; + font-size: 11px; + font-family: var(--font-meta); + color: var(--text-secondary); + } + .space-tag-chip:hover { + background: var(--bg-hover); + } + .space-tag-chip.on { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-text); + font-weight: 500; + } .filter-pill { padding: 2px 10px; border-radius: 99px; @@ -169,6 +241,19 @@ min-height: 0; overflow-y: auto; } + .group-header { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 16px 4px; + background: var(--bg); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-tertiary); + font-family: var(--font-meta); + } .note-row { display: block; width: 100%; @@ -200,6 +285,16 @@ text-overflow: ellipsis; white-space: nowrap; } + /* Reset UA mark styling (yellow bg, black text) so a search hit reads as + a subtle emphasis in both themes, matching pill/tag styling elsewhere. */ + .row-title mark, + .row-preview mark { + background: var(--accent-soft); + color: inherit; + font-weight: inherit; + border-radius: 4px; + padding: 0 1px; + } .row-date { color: var(--text-tertiary); font-size: 11px; diff --git a/src/lib/components/SettingsView.svelte b/src/lib/components/SettingsView.svelte index 832b6f8..b7636e5 100644 --- a/src/lib/components/SettingsView.svelte +++ b/src/lib/components/SettingsView.svelte @@ -1,14 +1,12 @@ @@ -20,62 +86,104 @@ -
Workspaces
+
Spaces
-
Tags
+
Tags
+{#if spaceMenu} + {@const menuWs = spaceMenu.ws} + (renamingSpaceId = menuWs.id) }, + { label: "Delete Space", danger: true, run: () => void deleteSpace(menuWs) }, + ]} + onclose={() => (spaceMenu = null)} + /> +{/if} + +{#if tagMenu} + {@const menuTag = tagMenu.tag} + (renamingTagId = menuTag.id) }, + { label: "Delete Tag", danger: true, run: () => void confirmDeleteTag(menuTag) }, + ]} + onclose={() => (tagMenu = null)} + /> +{/if} + diff --git a/src/lib/components/SidebarEntityRow.test.ts b/src/lib/components/SidebarEntityRow.test.ts new file mode 100644 index 0000000..02c501c --- /dev/null +++ b/src/lib/components/SidebarEntityRow.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import SidebarEntityRow from "./SidebarEntityRow.svelte"; + +afterEach(cleanup); + +function base(overrides: Record = {}) { + return { + name: "Projects", + count: 3, + normalize: (s: string) => s.trim(), + noun: "Space", + active: false, + editing: false, + onSelect: vi.fn(), + onStartRename: vi.fn(), + onRename: vi.fn().mockResolvedValue({ ok: true }), + onDoneRename: vi.fn(), + onMenu: vi.fn(), + ...overrides, + }; +} + +describe("SidebarEntityRow", () => { + it("renders name (with prefix) and count, and selects on click", async () => { + const props = base({ prefix: "#", name: "idea", count: 5 }); + const { getByRole } = render(SidebarEntityRow, props); + const button = getByRole("button"); + expect(button.textContent).toContain("#idea"); + expect(button.textContent).toContain("5"); + await fireEvent.click(button); + expect(props.onSelect).toHaveBeenCalledTimes(1); + }); + + it("opens the context menu on right-click", async () => { + const props = base(); + const { getByRole } = render(SidebarEntityRow, props); + await fireEvent.contextMenu(getByRole("button")); + expect(props.onMenu).toHaveBeenCalledTimes(1); + }); + + it("commits a normalized new name on Enter and finishes editing", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: " new name " } }); + await fireEvent.keyDown(input, { key: "Enter" }); + expect(props.onRename).toHaveBeenCalledWith("new name"); + await waitFor(() => expect(props.onDoneRename).toHaveBeenCalledTimes(1)); + }); + + it("shows an error and does not call onRename when the name is empty", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole, findByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: " " } }); + await fireEvent.keyDown(input, { key: "Enter" }); + const alert = await findByRole("alert"); + expect(alert.textContent).toContain("Space name can't be empty"); + expect(props.onRename).not.toHaveBeenCalled(); + }); + + it("cancels on Escape without renaming", async () => { + const props = base({ editing: true, name: "old" }); + const { getByRole } = render(SidebarEntityRow, props); + const input = getByRole("textbox") as HTMLInputElement; + await fireEvent.input(input, { target: { value: "changed" } }); + await fireEvent.keyDown(input, { key: "Escape" }); + expect(props.onRename).not.toHaveBeenCalled(); + await waitFor(() => expect(props.onDoneRename).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..6271d0c --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,92 @@ + + +
+ {#each toasts.items as toast (toast.id)} +
toasts.pause(toast.id)} + onmouseleave={() => toasts.resume(toast.id)} + > + {toast.message} +
+ {#if toast.action} + + {/if} + +
+
+ {/each} +
+ + diff --git a/src/lib/components/WelcomeScreen.svelte b/src/lib/components/WelcomeScreen.svelte index e154a03..258bb19 100644 --- a/src/lib/components/WelcomeScreen.svelte +++ b/src/lib/components/WelcomeScreen.svelte @@ -1,10 +1,27 @@
@@ -42,6 +59,15 @@

Select a note, or press {captureShortcut} anywhere to capture.

Press {modKey}K for commands and themes.

+ {#if shortcutConflict && !conflictDismissed} +

+ The capture shortcut {shortcutConflict} could not be registered; + another app likely owns it. + +

+ {/if} {#if library.error}

{library.error}

{/if}
@@ -65,6 +91,21 @@ font-size: 12px; opacity: 0.8; } + /* Same warning orange as the beta badge; a conflict is a caution, not an error. */ + .shortcut-notice { + font-size: 12px; + color: #e8923a; + } + .notice-dismiss { + margin-left: 4px; + border: 1px solid #e8923a; + border-radius: 99px; + padding: 0 7px; + color: #e8923a; + background: rgba(232, 146, 58, 0.08); + font-size: 10px; + cursor: pointer; + } /* Orange marks beta builds. */ .version-badge { display: inline-block; diff --git a/src/lib/components/settings/SettingsAbout.svelte b/src/lib/components/settings/SettingsAbout.svelte new file mode 100644 index 0000000..9e77867 --- /dev/null +++ b/src/lib/components/settings/SettingsAbout.svelte @@ -0,0 +1,140 @@ + + +
+ InstantNotes +

InstantNotes

+ {#if appVersion} + v{appVersion} + {/if} +

Instant capture, organized knowledge.

+ +
+
+ Version + {appVersion || "-"} +
+
+
+ Platform + macOS · Apple Silicon +
+
+
+ Capture readiness + + {#if captureLatency && captureLatency.medianMs !== null} + {captureLatency.medianMs} ms + {:else} + Measured on first capture + {/if} + +
+
+
+ Source + +
+
+
+ + diff --git a/src/lib/components/settings/SettingsContexting.svelte b/src/lib/components/settings/SettingsContexting.svelte new file mode 100644 index 0000000..fc357b0 --- /dev/null +++ b/src/lib/components/settings/SettingsContexting.svelte @@ -0,0 +1,123 @@ + + +
+

Contexting

+

+ The template behind "Copy note as context" in the {modKey}K palette. Wrap the note + however a tool or model expects; this is the seed for InstantNotes' AI features. +

+ + + + +
+ {#each TEMPLATE_VARS as v} + {v} + {/each} +
+ + Preview +
{preview}
+
+ + diff --git a/src/lib/components/settings/SettingsLinks.svelte b/src/lib/components/settings/SettingsLinks.svelte new file mode 100644 index 0000000..fe5aa6b --- /dev/null +++ b/src/lib/components/settings/SettingsLinks.svelte @@ -0,0 +1,253 @@ + + + + + diff --git a/src/lib/editor/ARCHITECTURE.md b/src/lib/editor/ARCHITECTURE.md new file mode 100644 index 0000000..cf8c99b --- /dev/null +++ b/src/lib/editor/ARCHITECTURE.md @@ -0,0 +1,86 @@ +# The editor kernel + +`src/lib/editor/` is the platform layer between CodeMirror 6 and every +markdown feature InstantNotes has or will have. It exists because the previous +design (one self-contained extension per feature) let five separate tree walks +each invent their own answer to "what is hidden, where may the caret go, what +is clickable," and those answers drifted apart. Every editor bug shipped so +far was one of those disagreements. + +The kernel replaces the seams with one pipeline: + + lezer parse -> ConstructScanner (ONE walk) -> ConstructTable + |-- decorations (render) + |-- atomic ranges (caret legality) + |-- reveal state (what shows raw) + |-- hit info (what a click means) + +Features are `ConstructSpec` classes registered with the scanner. A spec +declares what a construct looks like; it never touches the view, events, or +other constructs. The kernel derives rendering, caret rules, and click routing +from the same table, so they cannot disagree by construction. + +## The experience contract + +These are invariants of the whole editing experience, not per-feature rules. +`kernel.test.ts` enforces the testable ones over a corpus of every construct. + +1. WRITING. A keystroke always lands visibly. No zero-width hidden range may + touch the caret: any construct the selection touches (boundary inclusive) + shows its raw syntax while touched. Enter continues lists, quotes, and + tasks (markdownKeymap). +2. DELETING. Markers behave like objects. Backspace at a marker boundary + removes the marker whole (keymap + atomic ranges); deletion can never eat + text the eye has not seen. +3. SELECTING. Anything a selection covers is fully visible while covered, so + copy and delete operate on exactly what is on screen. +4. CLICKING. The caret lands where the eye says. A click past the visible end + of a line goes to the true end of line, never invisibly inside markup. + Clickability, pointer cursor, tooltip, and open target all derive from the + construct table. +5. READING. Constructs reveal per smallest sensible unit (a quote line, not + the whole quote), widgets are stable across rebuilds (eq()), and the whole + system costs one tree walk per update, viewport scoped, at any note size. + +## Modules + +Each module is directly specified: purpose, construction, public surface. + +- `types.ts`: the construct model. `ConstructSpec` (tree driven), `TextSpec` + (text driven, e.g. tags), `Emit` (what a spec may declare: construct spans, + hides, marks, line chrome), `RevealMode` (`span` reveals on touch, `never` + is widget-stable). +- `scanner.ts`: `ConstructScanner` class. Constructor takes the spec + registry; `scan(state, ranges, preview)` performs the single syntax-tree + walk and returns a `ConstructTable`. `ConstructTable` encapsulates the scan + result; public queries: `decorations(sel)`, `atomicRanges(sel)`, + `constructAt(pos)`. Pure over EditorState, fully unit testable. +- `reveal.ts`: the one reveal predicate. `revealed(construct, selFrom, selTo)` + boundary inclusive. No other module may reimplement this comparison. +- `kernel.ts`: `previewModeField` and the `PreviewKernel` ViewPlugin that owns + the scan lifecycle (doc, viewport, selection, mode, prefs) and exposes the + table to the view layer. +- `caret.ts`: `CaretGuard`. Atomic ranges wiring plus pointer normalization + (contract 4). The only module allowed to touch selection placement. +- `constructs/`: one spec class per construct: inline marks, link, heading, + fence, table, list, quote, hr, task, image, tags. Parity with the retired + wysiwyg.ts, link-click.ts, task-list.ts, image-preview.ts behavior. +- `links.ts`, `images.ts`, `tasks.ts`, `blocks.ts`: behavior modules (open + routing, paste/drop capture, toggle, marker backspace) plus their pure, + tested helpers. State fields and effects keep their old names. +- `theme.ts`: all kernel base themes in one place. +- `index.ts`: `editorKernel(opts)`, the only entry point. Editor.svelte + consumes this and nothing else from the kernel. + +Outside the kernel, unchanged by design: parsing (`markdown-extensions.ts`), +paint (`markdown-highlight.ts`), commands (`markdown-format.ts`, +`list-indent.ts`), queries (`markdown-active.ts`). They are upstream or +downstream of the kernel, not seams inside it. + +## Adding a construct + +Write one `ConstructSpec` class in `constructs/`, register it in `index.ts`, +add corpus lines to `kernel.test.ts`. Do not add a ViewPlugin, a decoration +set, an event handler, or a `touches` comparison anywhere else. If a new +feature seems to need one of those, it is a kernel capability; extend the +kernel once, for everyone. diff --git a/src/lib/wysiwyg.test.ts b/src/lib/editor/blocks.test.ts similarity index 81% rename from src/lib/wysiwyg.test.ts rename to src/lib/editor/blocks.test.ts index 9554446..6cb7b40 100644 --- a/src/lib/wysiwyg.test.ts +++ b/src/lib/editor/blocks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { blockMarkerRange } from "./wysiwyg"; +import { blockMarkerRange } from "./blocks"; describe("blockMarkerRange", () => { it("returns null for plain text", () => { @@ -47,4 +47,13 @@ describe("blockMarkerRange", () => { // " - item": indent=2, marker="-", space=1 → marker occupies [2,4), text starts at 4 expect(blockMarkerRange(" - item", 0)).toEqual({ from: 2, to: 4 }); }); + + it("detects heading markers (# )", () => { + expect(blockMarkerRange("# Title", 0)).toEqual({ from: 0, to: 2 }); + expect(blockMarkerRange("### Deep", 10)).toEqual({ from: 10, to: 14 }); + }); + + it("does not treat a #tag (no space) as a heading marker", () => { + expect(blockMarkerRange("#tag stays", 0)).toBeNull(); + }); }); diff --git a/src/lib/editor/blocks.ts b/src/lib/editor/blocks.ts new file mode 100644 index 0000000..d09e1ca --- /dev/null +++ b/src/lib/editor/blocks.ts @@ -0,0 +1,47 @@ +// Block markers as objects under deletion. Backspace with the caret at the +// end of a leading block marker removes the whole marker, so a bullet or +// heading prefix dies in one keystroke instead of shedding characters. The +// kernel's atomic ranges give widget markers the same treatment during +// normal cursor motion and deletion; this keymap covers the revealed ones +// (heading #, quote >) and must be registered above defaultKeymap, whose +// deleteCharBackward would otherwise always win. + +import { keymap } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { previewModeField } from "./kernel"; + +/** + * Given a line's text and its document-start offset, returns the range + * occupied by the block marker (including trailing whitespace), or null if + * the line does not start with one. + */ +export function blockMarkerRange( + lineText: string, + lineFrom: number, +): { from: number; to: number } | null { + const m = lineText.match(/^(\s*)(>\s*|[-*+]\s+|\d+\.\s+|#{1,6}\s+)/); + if (!m) return null; + return { from: lineFrom + m[1].length, to: lineFrom + m[0].length }; +} + +export function markerBackspaceKeymap(): Extension { + return keymap.of([ + { + key: "Backspace", + run(view) { + if (!view.state.field(previewModeField)) return false; + const sel = view.state.selection.main; + if (!sel.empty) return false; + + const line = view.state.doc.lineAt(sel.from); + const range = blockMarkerRange(line.text, line.from); + if (!range || sel.from !== range.to) return false; + + view.dispatch({ + changes: { from: range.from, to: range.to, insert: "" }, + }); + return true; + }, + }, + ]); +} diff --git a/src/lib/editor/caret.ts b/src/lib/editor/caret.ts new file mode 100644 index 0000000..2fe312e --- /dev/null +++ b/src/lib/editor/caret.ts @@ -0,0 +1,79 @@ +// CaretGuard: the only module allowed to influence selection placement. +// +// CM6's atomic ranges (wired in kernel.ts) already keep cursor MOTION and +// deletion out of folded markup. Pointer placement is the remaining gap: +// with `](url)` folded away, the DOM's last caret position on the line sits +// at the end of the visible link text, so a click in the blank space to the +// right of the line resolves just before the `]` and typing would extend the +// link. The guard detects exactly that case (everything between the resolved +// position and the end of the line is folded, and the click landed past the +// last glyph) and places the caret at the true end of the line instead: +// where the eye says it clicked. +// +// Implemented with EditorView.mouseSelectionStyle, the sanctioned hook, so +// drag selections keep working: every drag event maps through the same +// correction. + +import { EditorView, type MouseSelectionStyle } from "@codemirror/view"; +import { EditorSelection, type Extension } from "@codemirror/state"; +import { previewModeField, type Kernel } from "./kernel"; + +export class CaretGuard { + constructor(private readonly kernel: Kernel["plugin"]) {} + + extension(): Extension { + return EditorView.mouseSelectionStyle.of((view, event) => + this.#style(view, event), + ); + } + + #style(view: EditorView, event: MouseEvent): MouseSelectionStyle | null { + // Multi-clicks are word/line selection; leave them native. + if (event.button !== 0 || event.detail > 1) return null; + if (!(view.state.field(previewModeField, false) ?? false)) return null; + const start = this.#corrected(view, event); + if (start === null) return null; + const guard = this; + return { + get(cur, extend, multiple) { + const head = guard.#corrected(view, cur) ?? guard.#natural(view, cur); + const anchor = extend ? view.state.selection.main.anchor : start; + const range = EditorSelection.range(anchor, head); + if (multiple) { + const ranges = view.state.selection.ranges; + return EditorSelection.create([...ranges, range], ranges.length); + } + return EditorSelection.create([range]); + }, + update() {}, + }; + } + + #natural(view: EditorView, e: MouseEvent): number { + return ( + view.posAtCoords({ x: e.clientX, y: e.clientY }) ?? + view.state.selection.main.head + ); + } + + /** + * The position this event should place the caret at, or null when the + * native mapping is already truthful. Foldedness is judged against the + * selection at click time, because that is what was on screen when the + * user aimed. + */ + #corrected(view: EditorView, e: MouseEvent): number | null { + const k = view.plugin(this.kernel); + if (!k) return null; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }); + if (pos === null) return null; + const line = view.state.doc.lineAt(pos); + if (pos === line.to) return null; + if (!k.table.allHiddenBetween(pos, line.to, view.state.selection.ranges)) { + return null; + } + const rect = view.coordsAtPos(pos, -1) ?? view.coordsAtPos(pos, 1); + if (!rect || e.clientX <= rect.right + 1) return null; + return line.to; + } +} diff --git a/src/lib/editor/constructs/fence.ts b/src/lib/editor/constructs/fence.ts new file mode 100644 index 0000000..05aa81d --- /dev/null +++ b/src/lib/editor/constructs/fence.ts @@ -0,0 +1,34 @@ +// Code fence construct: block chrome on every fence line, the backtick +// fence marks fold, and the language word stays as a small label. Touching +// the fence reveals the backticks; the chrome stays either way, so the block +// reads as a block even while being edited. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const codeblockLine = Decoration.line({ class: "cm-wysiwyg-codeblock" }); +const codeinfoMark = Decoration.mark({ class: "cm-wysiwyg-codeinfo" }); + +export class FenceSpec implements ConstructSpec { + readonly nodes = ["FencedCode"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const doc = cx.state.doc; + const first = doc.lineAt(node.from).number; + const last = doc.lineAt(node.to).number; + for (let ln = first; ln <= last; ln++) { + emit.line(doc.line(ln).from, codeblockLine); + } + const owner = emit.construct(node.from, node.to, "span"); + for (let c = node.node.firstChild; c; c = c.nextSibling) { + if (c.name === "CodeMark") { + emit.hide(owner, c.from, c.to); + } else if (c.name === "CodeInfo") { + emit.mark(c.from, c.to, codeinfoMark); + } + } + return false; + } +} diff --git a/src/lib/editor/constructs/heading.ts b/src/lib/editor/constructs/heading.ts new file mode 100644 index 0000000..de21131 --- /dev/null +++ b/src/lib/editor/constructs/heading.ts @@ -0,0 +1,24 @@ +// ATX heading construct: the `# ` prefix folds in preview (the heading text +// is sized by the highlight style); touching the heading line reveals it. +// Setext underlines are left alone: hiding the underline line would collapse +// it to nothing. +// +// Headings and tags coexist without ambiguity: CommonMark only parses +// `# Heading` (with a space) as a heading, and the tag construct only +// matches `#tag` (no space). + +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class HeadingSpec implements ConstructSpec { + readonly nodes = ["HeaderMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + const parent = node.node.parent; + if (!parent || !/^ATXHeading/.test(parent.name)) return false; + const owner = emit.construct(parent.from, parent.to, "span"); + const after = cx.state.doc.sliceString(node.to, node.to + 1); + emit.hide(owner, node.from, after === " " ? node.to + 1 : node.to); + return false; + } +} diff --git a/src/lib/editor/constructs/hr.ts b/src/lib/editor/constructs/hr.ts new file mode 100644 index 0000000..c268f18 --- /dev/null +++ b/src/lib/editor/constructs/hr.ts @@ -0,0 +1,35 @@ +// Horizontal rule construct: `---` renders as an actual rule. Caret contact +// reveals the raw dashes so the rule stays editable; the widget lets clicks +// through so CM places the caret, which is what reveals it. + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class HrWidget extends WidgetType { + eq(): boolean { + return true; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-hr"; + return s; + } + // Let CM place the caret on click, which reveals the raw `---`. + ignoreEvent(): boolean { + return false; + } +} + +const hrDeco = Decoration.replace({ widget: new HrWidget() }); + +export class HrSpec implements ConstructSpec { + readonly nodes = ["HorizontalRule"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const owner = emit.construct(node.from, node.to, "span"); + emit.hide(owner, node.from, node.to, hrDeco); + return false; + } +} diff --git a/src/lib/editor/constructs/image.ts b/src/lib/editor/constructs/image.ts new file mode 100644 index 0000000..88dad2e --- /dev/null +++ b/src/lib/editor/constructs/image.ts @@ -0,0 +1,66 @@ +// Image construct: `![alt](attachments/)` renders as the actual image +// through Tauri's asset protocol; caret contact brings the raw markdown back +// so it stays editable. Remote http(s) images stay as text: the CSP +// deliberately blocks remote loads, and the link layer opens them externally +// on click. The converter is injected so the spec stays testable without a +// Tauri runtime. + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { attachmentSrc, attachmentsBaseField } from "../images"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class ImageWidget extends WidgetType { + constructor( + readonly src: string, + readonly alt: string, + ) { + super(); + } + eq(o: ImageWidget): boolean { + return o.src === this.src && o.alt === this.alt; + } + toDOM(): HTMLElement { + const img = document.createElement("img"); + img.className = "cm-image-preview"; + img.src = this.src; + img.alt = this.alt; + img.draggable = false; + return img; + } + // Let CM handle clicks: the caret lands at the image's position, which + // reveals the raw markdown for editing. + ignoreEvent(): boolean { + return false; + } +} + +export class ImageSpec implements ConstructSpec { + readonly nodes = ["Image"]; + + constructor(private readonly convert: (path: string) => string) {} + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + // Always descend so the URL child keeps its link mark (visible in edit + // mode and whenever the image is revealed). + if (!cx.preview) return true; + const url = node.node.getChild("URL"); + if (!url) return true; + const src = attachmentSrc( + cx.state.doc.sliceString(url.from, url.to), + cx.state.field(attachmentsBaseField), + this.convert, + ); + if (!src) return true; + const alt = + cx.state.doc.sliceString(node.from, node.to).match(/^!\[([^\]]*)\]/)?.[1] ?? ""; + const owner = emit.construct(node.from, node.to, "span"); + emit.hide( + owner, + node.from, + node.to, + Decoration.replace({ widget: new ImageWidget(src, alt) }), + ); + return true; + } +} diff --git a/src/lib/editor/constructs/inline-marks.ts b/src/lib/editor/constructs/inline-marks.ts new file mode 100644 index 0000000..74ccdb3 --- /dev/null +++ b/src/lib/editor/constructs/inline-marks.ts @@ -0,0 +1,24 @@ +// Inline mark constructs: **bold**, *italic*, ~~strike~~, `code`, and +// ==highlight== markers fold away in preview; the styled text (painted by +// markdown-highlight.ts) stays. The construct span is the whole parent, so +// touching any part of **bold** reveals both markers together. + +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class InlineMarkSpec implements ConstructSpec { + readonly nodes = [ + "EmphasisMark", + "StrikethroughMark", + "CodeMark", + "HighlightMark", + ]; + + enter(node: SyntaxNodeRef, _cx: ScanContext, emit: Emit): boolean { + const parent = node.node.parent; + if (!parent) return false; + const owner = emit.construct(parent.from, parent.to, "span"); + emit.hide(owner, node.from, node.to); + return false; + } +} diff --git a/src/lib/editor/constructs/link.ts b/src/lib/editor/constructs/link.ts new file mode 100644 index 0000000..5bf8a5b --- /dev/null +++ b/src/lib/editor/constructs/link.ts @@ -0,0 +1,64 @@ +// Link constructs: [text](url), , and bare GFM URLs. +// +// Preview folds the [ and ](url) markup and keeps the text; touching the +// link reveals the whole thing. The appearance mark (underline, pointer, +// tooltip, external indicator) derives from linkPrefsField here, in both +// modes, so what looks clickable and what linkOpenHandler opens can never +// disagree. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { linkAt, linkMarkClass, linkPrefsField } from "../links"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +export class LinkSpec implements ConstructSpec { + readonly nodes = ["Link", "Autolink", "URL"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (node.name === "Link") { + const owner = emit.construct(node.from, node.to, "span"); + const raw = cx.state.doc.sliceString(node.from, node.to); + const split = raw.indexOf("]("); + if (split !== -1) { + emit.hide(owner, node.from, node.from + 1); + emit.hide(owner, node.from + split, node.to); + } + this.#mark(node.from, node.to, cx, emit); + // Descend: emphasis inside link text folds via its own construct. + return true; + } + + if (node.name === "Autolink") { + const owner = emit.construct(node.from, node.to, "span"); + for (let c = node.node.firstChild; c; c = c.nextSibling) { + if (c.name === "LinkMark") emit.hide(owner, c.from, c.to); + } + this.#mark(node.from, node.to, cx, emit); + return false; + } + + // URL: a bare GFM autolink, or the URL inside an Image (link-styled in + // edit mode; in preview the image widget covers it). URLs wrapped by + // Link/Autolink are already marked at the wrapper. + const parent = node.node.parent; + if (parent && (parent.name === "Link" || parent.name === "Autolink")) { + return false; + } + this.#mark(node.from, node.to, cx, emit); + return false; + } + + #mark(from: number, to: number, cx: ScanContext, emit: Emit): void { + const href = linkAt(cx.state, from); + if (!href) return; + const prefs = cx.state.field(linkPrefsField); + emit.mark( + from, + to, + Decoration.mark({ + class: linkMarkClass(prefs, cx.preview), + attributes: prefs.tooltip ? { title: href } : undefined, + }), + ); + } +} diff --git a/src/lib/editor/constructs/list.ts b/src/lib/editor/constructs/list.ts new file mode 100644 index 0000000..2c3657e --- /dev/null +++ b/src/lib/editor/constructs/list.ts @@ -0,0 +1,73 @@ +// List marker constructs: - / * / + render as a bullet, 1. as its number. +// These are "never" reveal: the marker stays an object. The kernel's atomic +// ranges make the caret step over it whole and backspace delete it whole +// (with markerBackspaceKeymap covering the revealed-marker cases). + +import { Decoration, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class BulletWidget extends WidgetType { + // All bullets are identical; report equality so the DOM node survives + // rebuilds instead of flickering. + eq(): boolean { + return true; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-bullet"; + s.textContent = "•"; + return s; + } + ignoreEvent(): boolean { + return true; + } +} + +class NumberWidget extends WidgetType { + constructor(readonly num: number) { + super(); + } + eq(o: NumberWidget): boolean { + return o.num === this.num; + } + toDOM(): HTMLElement { + const s = document.createElement("span"); + s.className = "cm-wysiwyg-number"; + s.textContent = `${this.num}.`; + return s; + } + ignoreEvent(): boolean { + return true; + } +} + +const bulletDeco = Decoration.replace({ widget: new BulletWidget() }); + +const LIST_RE = /^(\s*)([-*+]|\d+\.)\s+/; + +export class ListSpec implements ConstructSpec { + readonly nodes = ["ListMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const line = cx.state.doc.lineAt(node.from); + const m = line.text.match(LIST_RE); + if (!m) return false; + // The marker range covers marker char(s) plus trailing space, after any + // indent. + const from = line.from + m[1].length; + const to = line.from + m[0].length; + const owner = emit.construct(from, to, "never"); + const ordered = /^\d+\./.test(m[2]); + emit.hide( + owner, + from, + to, + ordered + ? Decoration.replace({ widget: new NumberWidget(parseInt(m[2], 10)) }) + : bulletDeco, + ); + return false; + } +} diff --git a/src/lib/editor/constructs/quote.ts b/src/lib/editor/constructs/quote.ts new file mode 100644 index 0000000..0d8a549 --- /dev/null +++ b/src/lib/editor/constructs/quote.ts @@ -0,0 +1,27 @@ +// Blockquote construct: quoted lines get the accent border treatment and +// the `>` prefix folds per line. The construct span is the line, not the +// whole quote, so a long quote does not flicker open as the caret crosses +// one of its lines. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const quoteLine = Decoration.line({ class: "cm-wysiwyg-blockquote" }); + +const QUOTE_RE = /^(>\s*)/; + +export class QuoteSpec implements ConstructSpec { + readonly nodes = ["QuoteMark"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + const line = cx.state.doc.lineAt(node.from); + const m = line.text.match(QUOTE_RE); + if (!m) return false; + emit.line(line.from, quoteLine); + const owner = emit.construct(line.from, line.to, "span"); + emit.hide(owner, line.from, line.from + m[0].length); + return false; + } +} diff --git a/src/lib/editor/constructs/table.ts b/src/lib/editor/constructs/table.ts new file mode 100644 index 0000000..ca82a8c --- /dev/null +++ b/src/lib/editor/constructs/table.ts @@ -0,0 +1,30 @@ +// Table construct: tables read as tables in preview. Monospace lines so +// columns align, bold header row, dimmed delimiter row. Structure only; the +// pipes stay, because a full grid rebuild would fight the caret. + +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +const tableLine = Decoration.line({ class: "cm-wysiwyg-table" }); +const headMark = Decoration.mark({ class: "cm-wysiwyg-tablehead" }); +const delimMark = Decoration.mark({ class: "cm-wysiwyg-tabledelim" }); + +export class TableSpec implements ConstructSpec { + readonly nodes = ["Table", "TableHeader", "TableDelimiter"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + if (!cx.preview) return false; + if (node.name === "Table") { + const doc = cx.state.doc; + const first = doc.lineAt(node.from).number; + const last = doc.lineAt(node.to).number; + for (let ln = first; ln <= last; ln++) { + emit.line(doc.line(ln).from, tableLine); + } + return true; + } + emit.mark(node.from, node.to, node.name === "TableHeader" ? headMark : delimMark); + return false; + } +} diff --git a/src/lib/editor/constructs/tags.ts b/src/lib/editor/constructs/tags.ts new file mode 100644 index 0000000..4bc5316 --- /dev/null +++ b/src/lib/editor/constructs/tags.ts @@ -0,0 +1,22 @@ +// Tag construct: #tag spans get the cm-tag treatment (styled in app.css) in +// both modes. Text-driven, not tree-driven: tags are an InstantNotes notion, +// not markdown. `#tag` (no space) never collides with `# Heading` (space +// required by CommonMark). + +import { Decoration } from "@codemirror/view"; +import type { Emit, ScanContext, TextSpec } from "../types"; + +const tagMark = Decoration.mark({ class: "cm-tag" }); + +const TAG_RE = /(^|\s)(#[\p{L}\p{N}_-]+)/gu; + +export class TagSpec implements TextSpec { + scan(text: string, offset: number, _cx: ScanContext, emit: Emit): void { + TAG_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TAG_RE.exec(text))) { + const start = offset + m.index + m[1].length; + emit.mark(start, start + m[2].length, tagMark); + } + } +} diff --git a/src/lib/editor/constructs/task.ts b/src/lib/editor/constructs/task.ts new file mode 100644 index 0000000..0b34c11 --- /dev/null +++ b/src/lib/editor/constructs/task.ts @@ -0,0 +1,69 @@ +// Task construct: GFM `[ ]` / `[x]` markers become native checkboxes in +// preview, and completed items get a struck, dimmed treatment in both modes +// so a burn-down list reads at a glance. The checkbox is a "never" reveal +// object: it stays a checkbox, the caret steps over it, and clicking it +// toggles the underlying marker character through taskToggleChange (an +// ordinary undoable edit). + +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { taskChecked, taskToggleChange } from "../tasks"; +import type { ConstructSpec, Emit, ScanContext } from "../types"; + +class CheckboxWidget extends WidgetType { + constructor(readonly checked: boolean) { + super(); + } + eq(o: CheckboxWidget): boolean { + return o.checked === this.checked; + } + toDOM(view: EditorView): HTMLElement { + const box = document.createElement("input"); + box.type = "checkbox"; + box.className = "cm-task-checkbox"; + box.checked = this.checked; + // posAtDOM at click time, not a stored offset: edits elsewhere in the + // note shift positions and a stale offset would toggle the wrong line. + box.addEventListener("mousedown", (e) => e.preventDefault()); + box.addEventListener("click", (e) => { + e.preventDefault(); + const pos = view.posAtDOM(box); + const line = view.state.doc.lineAt(pos); + const change = taskToggleChange(line.text, line.from); + if (change) view.dispatch({ changes: change }); + }); + return box; + } + ignoreEvent(): boolean { + // The widget owns its clicks; CM must not turn them into caret moves. + return true; + } +} + +const doneMark = Decoration.mark({ class: "cm-task-done" }); + +export class TaskSpec implements ConstructSpec { + readonly nodes = ["Task"]; + + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean { + const marker = node.node.getChild("TaskMarker"); + if (!marker) return false; + const checked = taskChecked(cx.state.doc.sliceString(marker.from, marker.to)); + if (cx.preview) { + // Swallow the trailing space too so the checkbox sits flush. + const after = cx.state.doc.sliceString(marker.to, marker.to + 1); + const to = after === " " ? marker.to + 1 : marker.to; + const owner = emit.construct(marker.from, to, "never"); + emit.hide( + owner, + marker.from, + to, + Decoration.replace({ widget: new CheckboxWidget(checked) }), + ); + } + if (checked && node.to > marker.to) { + emit.mark(marker.to, node.to, doneMark); + } + return false; + } +} diff --git a/src/lib/editor/images.test.ts b/src/lib/editor/images.test.ts new file mode 100644 index 0000000..250c92f --- /dev/null +++ b/src/lib/editor/images.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { attachmentSrc, extForMime, attachmentMarkdown } from "./images"; + +const convert = (p: string) => `asset://${p}`; + +describe("attachmentSrc", () => { + it("resolves a relative attachment through the converter", () => { + expect(attachmentSrc("attachments/a1.png", "/data/App", convert)).toBe( + "asset:///data/App/a1.png", + ); + }); + + it("returns null until the base directory is known", () => { + expect(attachmentSrc("attachments/a1.png", null, convert)).toBeNull(); + }); + + it("leaves remote URLs unresolved", () => { + expect(attachmentSrc("https://example.com/x.png", "/data/App", convert)).toBeNull(); + }); + + it("rejects traversal and nested paths", () => { + expect(attachmentSrc("attachments/../notes.db", "/data/App", convert)).toBeNull(); + expect(attachmentSrc("attachments/sub/x.png", "/data/App", convert)).toBeNull(); + expect(attachmentSrc("attachments/", "/data/App", convert)).toBeNull(); + }); +}); + +describe("extForMime", () => { + it("maps supported image types", () => { + expect(extForMime("image/png")).toBe("png"); + expect(extForMime("image/jpeg")).toBe("jpg"); + expect(extForMime("image/gif")).toBe("gif"); + expect(extForMime("image/webp")).toBe("webp"); + }); + + it("rejects everything else", () => { + expect(extForMime("image/svg+xml")).toBeNull(); + expect(extForMime("text/plain")).toBeNull(); + }); +}); + +describe("attachmentMarkdown", () => { + it("builds the relative markdown reference", () => { + expect(attachmentMarkdown("a1.png")).toBe("![](attachments/a1.png)"); + }); +}); diff --git a/src/lib/editor/images.ts b/src/lib/editor/images.ts new file mode 100644 index 0000000..86317d1 --- /dev/null +++ b/src/lib/editor/images.ts @@ -0,0 +1,137 @@ +// Attachment storage plumbing and image capture (paste/drop). +// +// Rendering lives in constructs/image.ts; this module owns the pure path +// resolution (unit-tested) and the capture flow: pasting or dropping an +// image file saves it as an attachment (bytes go to Rust, which owns the +// attachments directory) and inserts the relative markdown reference at the +// caret/drop point. + +import { EditorView } from "@codemirror/view"; +import { StateEffect, StateField, type Extension } from "@codemirror/state"; + +// --------------------------------------------------------------------------- +// Attachments base directory +// +// The absolute directory arrives async from Rust after the view exists, so it +// lives in a state field seeded by an effect. Until it lands, attachment +// images simply stay as markdown text. +// --------------------------------------------------------------------------- + +export const setAttachmentsBase = StateEffect.define(); + +export const attachmentsBaseField = StateField.define({ + create: () => null, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setAttachmentsBase)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested without a view) +// --------------------------------------------------------------------------- + +const ATTACHMENT_PREFIX = "attachments/"; + +/** + * Resolve a markdown image URL to something the webview can load, or null + * when it can't be rendered. Only relative attachment paths resolve; the + * `convert` parameter is Tauri's convertFileSrc, injected so this stays pure. + */ +export function attachmentSrc( + url: string, + base: string | null, + convert: (path: string) => string, +): string | null { + const u = url.trim(); + if (!u.startsWith(ATTACHMENT_PREFIX)) return null; + const name = u.slice(ATTACHMENT_PREFIX.length); + // A traversal like attachments/../notes.db must never reach the resolver. + if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) { + return null; + } + return base ? convert(`${base}/${name}`) : null; +} + +/** File extension for a pasteable image MIME type, or null to skip the file. */ +export function extForMime(mime: string): string | null { + switch (mime) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/gif": + return "gif"; + case "image/webp": + return "webp"; + default: + return null; + } +} + +/** The markdown inserted for a stored attachment. */ +export function attachmentMarkdown(name: string): string { + return `![](${ATTACHMENT_PREFIX}${name})`; +} + +function imageFiles(data: DataTransfer | null): File[] { + if (!data) return []; + return [...data.files].filter((f) => extForMime(f.type) !== null); +} + +// --------------------------------------------------------------------------- +// Paste / drop capture +// --------------------------------------------------------------------------- + +export interface ImageCaptureOpts { + /** Persist one image; resolves to the stored filename. */ + save: (bytes: Uint8Array, ext: string) => Promise; + onError?: (message: string) => void; +} + +export function imageCapture(opts: ImageCaptureOpts): Extension { + async function insertFiles(view: EditorView, files: File[], at: number) { + let pos = at; + for (const file of files) { + const ext = extForMime(file.type); + if (!ext) continue; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const name = await opts.save(bytes, ext); + const insert = attachmentMarkdown(name); + // The document may have changed while the save was in flight; clamp + // rather than dispatch out of range. + const clamped = Math.min(pos, view.state.doc.length); + view.dispatch({ + changes: { from: clamped, insert }, + selection: { anchor: clamped + insert.length }, + }); + pos = clamped + insert.length; + } catch (e) { + opts.onError?.(e instanceof Error ? e.message : String(e)); + } + } + } + + return EditorView.domEventHandlers({ + paste(e, view) { + const files = imageFiles(e.clipboardData); + if (files.length === 0) return false; + e.preventDefault(); + void insertFiles(view, files, view.state.selection.main.from); + return true; + }, + drop(e, view) { + const files = imageFiles(e.dataTransfer); + if (files.length === 0) return false; + e.preventDefault(); + const pos = + view.posAtCoords({ x: e.clientX, y: e.clientY }) ?? + view.state.selection.main.from; + void insertFiles(view, files, pos); + return true; + }, + }); +} diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts new file mode 100644 index 0000000..07b0684 --- /dev/null +++ b/src/lib/editor/index.ts @@ -0,0 +1,105 @@ +// The editor kernel's single entry point. Editor.svelte consumes this and +// nothing else from the kernel; everything the rest of the app needs +// (effects, fields, pure helpers, types) is re-exported here. +// +// See ARCHITECTURE.md in this directory for the experience contract and the +// module map. + +import { keymap } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { markdownKeymap } from "@codemirror/lang-markdown"; +import { convertFileSrc } from "@tauri-apps/api/core"; + +import { previewKernel } from "./kernel"; +import { CaretGuard } from "./caret"; +import { markerBackspaceKeymap } from "./blocks"; +import { linkOpenHandler, linkPrefsField } from "./links"; +import { attachmentsBaseField, imageCapture } from "./images"; +import { editModeTaskToggle } from "./tasks"; +import { kernelTheme } from "./theme"; + +import { InlineMarkSpec } from "./constructs/inline-marks"; +import { LinkSpec } from "./constructs/link"; +import { HeadingSpec } from "./constructs/heading"; +import { FenceSpec } from "./constructs/fence"; +import { TableSpec } from "./constructs/table"; +import { ListSpec } from "./constructs/list"; +import { QuoteSpec } from "./constructs/quote"; +import { HrSpec } from "./constructs/hr"; +import { TaskSpec } from "./constructs/task"; +import { ImageSpec } from "./constructs/image"; +import { TagSpec } from "./constructs/tags"; + +export interface EditorKernelOpts { + /** Open a normalized, scheme-checked URL externally (Rust open_url). */ + openUrl: (url: string) => void; + /** Persist one pasted/dropped image; resolves to the stored filename. */ + saveImage: (bytes: Uint8Array, ext: string) => Promise; + onImageError?: (message: string) => void; + /** Attachment path resolver; defaults to Tauri's convertFileSrc. */ + convertSrc?: (path: string) => string; +} + +/** + * The full editor platform bundle. Register it ABOVE defaultKeymap: the + * kernel owns Backspace (marker-as-object deletion) and Enter (list/quote + * continuation via markdownKeymap), which defaultKeymap would otherwise + * shadow. + */ +export function editorKernel(opts: EditorKernelOpts): Extension { + const { extension, plugin } = previewKernel({ + specs: [ + new InlineMarkSpec(), + new LinkSpec(), + new HeadingSpec(), + new FenceSpec(), + new TableSpec(), + new ListSpec(), + new QuoteSpec(), + new HrSpec(), + new TaskSpec(), + new ImageSpec(opts.convertSrc ?? convertFileSrc), + ], + textSpecs: [new TagSpec()], + rescanOn: [linkPrefsField, attachmentsBaseField], + }); + + return [ + linkPrefsField, + attachmentsBaseField, + // Keymap order within the bundle is precedence order: marker backspace + // first, then markdown's markup-aware Backspace/Enter continuation. + markerBackspaceKeymap(), + keymap.of(markdownKeymap), + extension, + new CaretGuard(plugin).extension(), + linkOpenHandler(opts.openUrl), + editModeTaskToggle(), + imageCapture({ save: opts.saveImage, onError: opts.onImageError }), + kernelTheme, + ]; +} + +// --------------------------------------------------------------------------- +// Public surface for the rest of the app +// --------------------------------------------------------------------------- + +export { previewModeField, setPreviewMode } from "./kernel"; +export { + setLinkPrefs, + linkPrefsField, + DEFAULT_LINK_PREFS, + normalizeHref, + linkAt, + linkMarkClass, +} from "./links"; +export type { LinkOpenWith, LinkUnderline, LinkPrefsSnapshot } from "./links"; +export { + setAttachmentsBase, + attachmentsBaseField, + attachmentSrc, + extForMime, + attachmentMarkdown, +} from "./images"; +export { taskToggleChange, taskChecked } from "./tasks"; +export { blockMarkerRange } from "./blocks"; diff --git a/src/lib/editor/kernel.test.ts b/src/lib/editor/kernel.test.ts new file mode 100644 index 0000000..134733b --- /dev/null +++ b/src/lib/editor/kernel.test.ts @@ -0,0 +1,295 @@ +// The kernel's experience contract, enforced as properties over a corpus of +// every construct (see ARCHITECTURE.md). These are not per-feature tests: +// they assert the invariants that make "typing into invisible markup" +// structurally impossible, for every construct at every caret position. + +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { highlightExtension } from "../markdown-extensions"; +import { ConstructScanner, type ConstructTable } from "./scanner"; +import { linkPrefsField } from "./links"; +import { attachmentsBaseField, setAttachmentsBase } from "./images"; +import { InlineMarkSpec } from "./constructs/inline-marks"; +import { LinkSpec } from "./constructs/link"; +import { HeadingSpec } from "./constructs/heading"; +import { FenceSpec } from "./constructs/fence"; +import { TableSpec } from "./constructs/table"; +import { ListSpec } from "./constructs/list"; +import { QuoteSpec } from "./constructs/quote"; +import { HrSpec } from "./constructs/hr"; +import { TaskSpec } from "./constructs/task"; +import { ImageSpec } from "./constructs/image"; +import { TagSpec } from "./constructs/tags"; + +// Mirrors the registry in index.ts, with a fake asset converter so no Tauri +// runtime is needed. +function makeScanner(): ConstructScanner { + return new ConstructScanner( + [ + new InlineMarkSpec(), + new LinkSpec(), + new HeadingSpec(), + new FenceSpec(), + new TableSpec(), + new ListSpec(), + new QuoteSpec(), + new HrSpec(), + new TaskSpec(), + new ImageSpec((p) => `asset://${p}`), + ], + [new TagSpec()], + ); +} + +function stateOf(doc: string): EditorState { + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: [highlightExtension] }), + linkPrefsField, + attachmentsBaseField, + ], + }); + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +function tableOf(state: EditorState, preview = true): ConstructTable { + return makeScanner().scan( + state, + [{ from: 0, to: state.doc.length }], + preview, + ); +} + +// One line (or block) per construct the kernel supports, blank-line +// separated so blocks terminate the way they do in real notes. +const CORPUS = [ + "plain text with #tag inline", + "", + "# Heading one", + "", + "## Second heading", + "", + "**bold** and *italic* and ~~gone~~ and `code`", + "", + "==marked== text", + "", + "a [link](https://example.com) mid line", + "", + "trailing [LINK_CLICK_ME](https://example.com/x)", + "", + " autolink", + "", + "bare https://example.com url", + "", + "- bullet item", + "", + "12. ordered item", + "", + "- [ ] open task", + "", + "- [x] done task", + "", + "> quoted line", + "", + "---", + "", + "```js", + "const x = 1;", + "```", + "", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "![alt](attachments/pic.png)", +].join("\n"); + +describe("kernel invariants", () => { + it("WRITING: no invisible fold ever touches the caret, at any position", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + for (let pos = 0; pos <= CORPUS.length; pos++) { + const folded = table.foldedHides([{ from: pos, to: pos }]); + for (const h of folded) { + if (h.widget) continue; // widgets are visible objects, guarded by atomic ranges + expect( + h.to < pos || h.from > pos, + `invisible hidden range [${h.from},${h.to}] "${CORPUS.slice(h.from, h.to)}" touches caret at ${pos}`, + ).toBe(true); + } + } + }); + + it("SELECTING: a selection reveals every non-widget construct it overlaps", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + const folded = table.foldedHides([{ from: 0, to: CORPUS.length }]); + expect(folded.every((h) => h.widget)).toBe(true); + }); + + it("the original complaint: caret at the end of a trailing link's text reveals the whole link", () => { + const doc = "trailing [LINK_CLICK_ME](https://example.com/x)"; + const table = tableOf(stateOf(doc)); + const pos = doc.indexOf("LINK_CLICK_ME") + "LINK_CLICK_ME".length; + expect(doc[pos]).toBe("]"); + const invisible = table + .foldedHides([{ from: pos, to: pos }]) + .filter((h) => !h.widget); + expect(invisible).toEqual([]); + }); + + it("CLICKING: past the visible end of a link line, everything to the line end is folded", () => { + const doc = "trailing [LINK_CLICK_ME](https://example.com/x)"; + const table = tableOf(stateOf(doc)); + const textEnd = doc.indexOf("LINK_CLICK_ME") + "LINK_CLICK_ME".length; + const away = [{ from: 0, to: 0 }]; // caret elsewhere, link folded + // From the last visible glyph to the line end is pure hidden markup, so + // CaretGuard snaps such a click to the line end. + expect(table.allHiddenBetween(textEnd, doc.length, away)).toBe(true); + // One glyph earlier is visible text: no snapping. + expect(table.allHiddenBetween(textEnd - 1, doc.length, away)).toBe(false); + }); + + it("caret legality: atomic ranges are exactly the folded hides", () => { + const state = stateOf(CORPUS); + const table = tableOf(state); + const sel = [{ from: 0, to: 0 }]; + const folded = table.foldedHides(sel); + const atomic = table.atomicRanges(sel); + let count = 0; + const cursor = atomic.iter(); + while (cursor.value !== null) { + count++; + cursor.next(); + } + expect(count).toBe(folded.length); + }); + + it("edit mode folds nothing", () => { + const state = stateOf(CORPUS); + const table = tableOf(state, false); + expect(table.foldedHides([{ from: 0, to: 0 }])).toEqual([]); + }); +}); + +describe("construct parity", () => { + function hiddenTexts(doc: string, sel = [{ from: 0, to: 0 }]): string[] { + const table = tableOf(stateOf(doc)); + return table.foldedHides(sel).map((h) => doc.slice(h.from, h.to)); + } + + it("folds link markup, keeps the text", () => { + expect(hiddenTexts("a [docs](https://x.dev) b")).toEqual([ + "[", + "](https://x.dev)", + ]); + }); + + it("folds autolink angle brackets", () => { + expect(hiddenTexts("go now")).toEqual(["<", ">"]); + }); + + it("folds inline emphasis marks, including inside link text", () => { + // Prefixed so the caret at 0 sits before the constructs (as the link and + // autolink cases above do); a caret touching a construct reveals it. + expect(hiddenTexts("a **b** [*i*](https://x.dev)")).toEqual([ + "**", + "**", + "[", + "*", + "*", + "](https://x.dev)", + ]); + }); + + it("folds the heading prefix", () => { + // Caret in the body, off the heading line: on the line, the prefix reveals. + expect(hiddenTexts("# Title\n\nbody", [{ from: 9, to: 9 }])).toEqual(["# "]); + }); + + it("folds highlight marks", () => { + expect(hiddenTexts("a ==hot== take")).toEqual(["==", "=="]); + }); + + it("folds the quote prefix per line: the caret's line opens, others stay", () => { + const doc = "> first\n> second"; + const table = tableOf(stateOf(doc)); + // Caret on the first quote line: its > opens, the second stays folded. + const folded = table.foldedHides([{ from: 2, to: 2 }]); + expect(folded.map((h) => h.from)).toEqual([doc.indexOf("> second")]); + }); + + it("replaces list markers with widgets", () => { + const doc = "- bullet\n\n12. numbered"; + const table = tableOf(stateOf(doc)); + const folded = table.foldedHides([{ from: doc.length, to: doc.length }]); + const listFolds = folded.filter((h) => h.widget); + expect(listFolds.map((h) => doc.slice(h.from, h.to))).toEqual(["- ", "12. "]); + }); + + it("keeps a checked task struck in both modes and its marker a widget in preview", () => { + const doc = "- [x] shipped"; + const state = stateOf(doc); + for (const preview of [true, false]) { + const table = tableOf(state, preview); + let struck = false; + table + .decorations([{ from: 0, to: 0 }]) + .between(0, doc.length, (_f, _t, deco) => { + if (deco.spec.class === "cm-task-done") struck = true; + }); + expect(struck, `cm-task-done missing (preview=${preview})`).toBe(true); + } + const folded = tableOf(state).foldedHides([{ from: doc.length, to: doc.length }]); + expect(folded.some((h) => h.widget && doc.slice(h.from, h.to) === "[x] ")).toBe(true); + }); + + it("renders attachment images as widgets once the base directory lands", () => { + // Lead-in text keeps the caret-at-0 selection off the image so it folds. + const doc = "pic:\n\n![alt](attachments/pic.png)"; + const caret = [{ from: 0, to: 0 }]; + const isImage = (h: { widget: boolean; from: number; to: number }) => + h.widget && doc.slice(h.from, h.to).startsWith("!["); + + // Before the async base directory arrives: markdown text, no widget. + expect(tableOf(stateOf(doc)).foldedHides(caret).some(isImage)).toBe(false); + + const withBase = stateOf(doc).update({ + effects: setAttachmentsBase.of("/data/App"), + }).state; + ensureSyntaxTree(withBase, doc.length, 5000); + expect(tableOf(withBase).foldedHides(caret).some(isImage)).toBe(true); + }); + + it("marks tags in both modes", () => { + const doc = "note about #spaces here"; + for (const preview of [true, false]) { + const table = tableOf(stateOf(doc), preview); + let tagged: string | null = null; + table.decorations([{ from: 0, to: 0 }]).between(0, doc.length, (f, t, deco) => { + if (deco.spec.class === "cm-tag") tagged = doc.slice(f, t); + }); + expect(tagged).toBe("#spaces"); + } + }); + + it("gives fence lines chrome and folds the fence marks", () => { + const doc = "```js\nconst x = 1;\n```"; + const state = stateOf(doc); + const table = tableOf(state); + const folded = table.foldedHides([{ from: 0, to: 0 }]); + // Caret inside the fence reveals the backticks (whole-fence construct). + expect(folded).toEqual([]); + const away = tableOf( + stateOf(`intro\n\n${doc}`), + ).foldedHides([{ from: 0, to: 0 }]); + expect(away.map((h) => `intro\n\n${doc}`.slice(h.from, h.to))).toEqual([ + "```", + "```", + ]); + }); +}); diff --git a/src/lib/editor/kernel.ts b/src/lib/editor/kernel.ts new file mode 100644 index 0000000..001c328 --- /dev/null +++ b/src/lib/editor/kernel.ts @@ -0,0 +1,116 @@ +// The preview kernel: owns the scan lifecycle and feeds the view. +// +// One PreviewKernel instance per editor. It rescans (one tree walk) only +// when the document, viewport, mode, or a watched preference field changes; +// a bare selection change just re-derives fold state from the cached table. +// Typing and arrow keys, the hottest paths, never re-walk the tree. + +import { + EditorView, + ViewPlugin, + Decoration, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import { + StateEffect, + StateField, + RangeSet, + type EditorState, + type Extension, +} from "@codemirror/state"; +import { ConstructScanner, type ConstructTable } from "./scanner"; +import type { ConstructSpec, TextSpec } from "./types"; + +// --------------------------------------------------------------------------- +// Mode state +// --------------------------------------------------------------------------- + +export const setPreviewMode = StateEffect.define(); + +export const previewModeField = StateField.define({ + create: () => false, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setPreviewMode)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// Kernel plugin +// --------------------------------------------------------------------------- + +export interface KernelConfig { + specs: readonly ConstructSpec[]; + textSpecs?: readonly TextSpec[]; + /** Fields whose value change forces a rescan (preference snapshots). */ + // deno-lint-ignore no-explicit-any StateField is invariant in its value type. + rescanOn?: readonly StateField[]; +} + +export class PreviewKernel { + table: ConstructTable; + decorations: DecorationSet = Decoration.none; + atomic: RangeSet = RangeSet.empty; + + constructor( + view: EditorView, + private readonly scanner: ConstructScanner, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly rescanOn: readonly StateField[], + ) { + this.table = this.#scan(view); + this.#derive(view.state); + } + + update(u: ViewUpdate): void { + const flip = + u.startState.field(previewModeField) !== u.state.field(previewModeField); + const prefsChanged = this.rescanOn.some( + (f) => u.startState.field(f, false) !== u.state.field(f, false), + ); + if (u.docChanged || u.viewportChanged || flip || prefsChanged) { + this.table = this.#scan(u.view); + this.#derive(u.state); + } else if (u.selectionSet) { + this.#derive(u.state); + } + } + + #scan(view: EditorView): ConstructTable { + return this.scanner.scan( + view.state, + view.visibleRanges, + view.state.field(previewModeField), + ); + } + + #derive(state: EditorState): void { + const sel = state.selection.ranges; + this.decorations = this.table.decorations(sel); + this.atomic = this.table.atomicRanges(sel); + } +} + +/** What previewKernel returns: the extension plus a handle for CaretGuard. */ +export interface Kernel { + extension: Extension; + plugin: ViewPlugin; +} + +export function previewKernel(config: KernelConfig): Kernel { + const scanner = new ConstructScanner(config.specs, config.textSpecs ?? []); + const plugin = ViewPlugin.define( + (view) => new PreviewKernel(view, scanner, config.rescanOn ?? []), + { + decorations: (v) => v.decorations, + // The documented idiom: expose the plugin's ranges to atomicRanges via + // view.plugin(), guarding against the plugin having been dropped. + provide: (p) => + EditorView.atomicRanges.of((view) => view.plugin(p)?.atomic ?? RangeSet.empty), + }, + ); + return { extension: [previewModeField, plugin], plugin }; +} diff --git a/src/lib/editor/links.test.ts b/src/lib/editor/links.test.ts new file mode 100644 index 0000000..5155ce5 --- /dev/null +++ b/src/lib/editor/links.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { linkAt, normalizeHref, linkMarkClass, DEFAULT_LINK_PREFS } from "./links"; + +// Same language setup as the editor (GFM base) so autolinks and bare URLs +// parse the way they do in the app. +function stateOf(doc: string): EditorState { + const state = EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage })], + }); + // linkAt reads the current tree; force a full parse up front since there + // is no view driving incremental parsing in tests. + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +describe("normalizeHref", () => { + it("passes http and https through", () => { + expect(normalizeHref("https://example.com")).toBe("https://example.com"); + expect(normalizeHref("http://example.com")).toBe("http://example.com"); + }); + + it("passes mailto through", () => { + expect(normalizeHref("mailto:a@b.com")).toBe("mailto:a@b.com"); + }); + + it("prepends https to bare www URLs", () => { + expect(normalizeHref("www.example.com")).toBe("https://www.example.com"); + }); + + it("rejects unsafe or unknown schemes", () => { + expect(normalizeHref("file:///etc/passwd")).toBeNull(); + expect(normalizeHref("javascript:alert(1)")).toBeNull(); + expect(normalizeHref("not a url")).toBeNull(); + }); +}); + +describe("linkMarkClass", () => { + it("defaults: underlined, clickable in preview, no indicator", () => { + expect(linkMarkClass(DEFAULT_LINK_PREFS, true)).toBe( + "cm-link-target cm-link-ul-always cm-link-clickable", + ); + }); + + it("is never clickable outside preview mode", () => { + expect(linkMarkClass(DEFAULT_LINK_PREFS, false)).toBe( + "cm-link-target cm-link-ul-always", + ); + }); + + it("drops the pointer when opening needs the modifier", () => { + expect( + linkMarkClass({ ...DEFAULT_LINK_PREFS, openWith: "modclick" }, true), + ).not.toContain("cm-link-clickable"); + }); + + it("carries underline and indicator choices", () => { + expect( + linkMarkClass( + { ...DEFAULT_LINK_PREFS, underline: "hover", externalIndicator: true }, + false, + ), + ).toBe("cm-link-target cm-link-ul-hover cm-link-ext"); + }); +}); + +describe("linkAt", () => { + it("finds the URL from inside [text](url) link text", () => { + const doc = "see [docs](https://example.com/docs) here"; + expect(linkAt(stateOf(doc), 6)).toBe("https://example.com/docs"); + }); + + it("finds the URL from inside the url part of a link", () => { + const doc = "see [docs](https://example.com/docs) here"; + expect(linkAt(stateOf(doc), 15)).toBe("https://example.com/docs"); + }); + + it("finds an autolink in angle brackets", () => { + const doc = "go now"; + expect(linkAt(stateOf(doc), 8)).toBe("https://example.com"); + }); + + it("finds a bare GFM autolink URL", () => { + const doc = "visit https://example.com today"; + expect(linkAt(stateOf(doc), 10)).toBe("https://example.com"); + }); + + it("normalizes a bare www autolink", () => { + const doc = "visit www.example.com today"; + expect(linkAt(stateOf(doc), 10)).toBe("https://www.example.com"); + }); + + it("returns null in plain text", () => { + expect(linkAt(stateOf("plain words only"), 3)).toBeNull(); + }); + + it("returns null just past the end of a link (trailing-space click)", () => { + const doc = "[docs](https://example.com)"; + expect(linkAt(stateOf(doc), doc.length)).toBeNull(); + }); + + it("returns null for links with unsafe schemes", () => { + const doc = "[bad](javascript:alert(1))"; + expect(linkAt(stateOf(doc), 2)).toBeNull(); + }); +}); diff --git a/src/lib/editor/links.ts b/src/lib/editor/links.ts new file mode 100644 index 0000000..ab4ddff --- /dev/null +++ b/src/lib/editor/links.ts @@ -0,0 +1,143 @@ +// Link preferences, URL resolution, and click-to-open routing. +// +// Interaction contract (user-tunable via the Links settings, carried in +// linkPrefsField): in preview mode (Aa toolbar closed) a plain click opens +// the link unless the user opted for Cmd/Ctrl+click everywhere. With the +// toolbar open the document is raw markdown being edited, so opening always +// requires Cmd/Ctrl+click and a plain click just places the caret. +// +// Appearance marks are emitted by constructs/link.ts from the same prefs, so +// the pointer cursor, underline, tooltip, and open target cannot disagree. + +import { syntaxTree } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { + StateEffect, + StateField, + type EditorState, + type Extension, +} from "@codemirror/state"; +import { previewModeField } from "./kernel"; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/** How a link opens in preview mode ("click") or only ever with Cmd/Ctrl. */ +export type LinkOpenWith = "click" | "modclick"; +export type LinkUnderline = "always" | "hover" | "never"; + +export interface LinkPrefsSnapshot { + openWith: LinkOpenWith; + underline: LinkUnderline; + /** Show the destination as a hover tooltip (the URL is hidden in preview). */ + tooltip: boolean; + /** Trailing arrow indicator so outbound links read as "leaves the app". */ + externalIndicator: boolean; +} + +export const DEFAULT_LINK_PREFS: LinkPrefsSnapshot = { + openWith: "click", + underline: "always", + tooltip: true, + externalIndicator: false, +}; + +export const setLinkPrefs = StateEffect.define(); + +export const linkPrefsField = StateField.define({ + create: () => DEFAULT_LINK_PREFS, + update(val, tr) { + for (const e of tr.effects) { + if (e.is(setLinkPrefs)) return e.value; + } + return val; + }, +}); + +// --------------------------------------------------------------------------- +// URL resolution (pure, unit-tested) +// --------------------------------------------------------------------------- + +/** + * Restrict opening to schemes that are safe to hand to the OS opener. Bare + * `www.` URLs (GFM autolinks carry no scheme) get https prepended so the + * opener does not reject them. + */ +export function normalizeHref(raw: string): string | null { + const url = raw.trim(); + if (/^https?:\/\//i.test(url) || /^mailto:/i.test(url)) return url; + if (/^www\./i.test(url)) return `https://${url}`; + return null; +} + +/** + * The href of the link enclosing `pos`, or null when the position is not on + * a link. The right boundary is exclusive so a click in the whitespace after + * a link (which resolves to the link's end position) does not open it. + */ +export function linkAt(state: EditorState, pos: number): string | null { + const tree = syntaxTree(state); + for (const side of [1, -1] as const) { + let node: ReturnType | null = tree.resolveInner(pos, side); + while (node) { + if (node.name === "Link" || node.name === "Image" || node.name === "Autolink") { + if (pos < node.from || pos >= node.to) return null; + const url = node.getChild("URL"); + return url ? normalizeHref(state.doc.sliceString(url.from, url.to)) : null; + } + if (node.name === "URL") { + if (pos < node.from || pos >= node.to) return null; + return normalizeHref(state.doc.sliceString(node.from, node.to)); + } + node = node.parent; + } + } + return null; +} + +/** CSS classes for a link mark under the given prefs/mode. Pure for tests. */ +export function linkMarkClass(prefs: LinkPrefsSnapshot, preview: boolean): string { + let cls = `cm-link-target cm-link-ul-${prefs.underline}`; + if (preview && prefs.openWith === "click") cls += " cm-link-clickable"; + if (prefs.externalIndicator) cls += " cm-link-ext"; + return cls; +} + +// --------------------------------------------------------------------------- +// Click handling +// --------------------------------------------------------------------------- + +/** + * The open-on-click handler. `open` receives a normalized, scheme-checked + * URL. The click must land on rendered link text: the mark decoration spans + * exactly that, so anchoring on it keeps the pointer cursor and clickability + * in agreement. Coordinate mapping alone was too forgiving: clicks past the + * end of a line, or over syntax hidden by preview replace-decorations, + * resolve to a position inside the Link node and opened links the pointer + * never touched. + */ +export function linkOpenHandler(open: (url: string) => void): Extension { + return EditorView.domEventHandlers({ + mousedown(e, view) { + if (e.button !== 0 || e.shiftKey || e.altKey) return false; + const target = + e.target instanceof Element ? e.target.closest(".cm-link-target") : null; + if (!target) return false; + const preview = view.state.field(previewModeField, false) ?? false; + const prefs = view.state.field(linkPrefsField); + const mod = e.metaKey || e.ctrlKey; + // Plain click only opens in preview mode with the "click" preference; + // edit mode always needs Cmd/Ctrl so clicks can place the caret. + if (!mod && !(preview && prefs.openWith === "click")) return false; + const pos = view.posAtDOM(target, 0); + const url = linkAt(view.state, pos); + if (!url) return false; + // Swallow the event: without this a Cmd+click also spawns a second + // CM6 cursor, and a preview click would move the caret into markup. + e.preventDefault(); + open(url); + return true; + }, + }); +} diff --git a/src/lib/editor/reveal.ts b/src/lib/editor/reveal.ts new file mode 100644 index 0000000..dd67905 --- /dev/null +++ b/src/lib/editor/reveal.ts @@ -0,0 +1,45 @@ +// The one reveal predicate. No other module may compare a selection to a +// construct span; if a module needs the comparison, it imports this. The +// previous architecture had three private copies of this logic and every +// "typing lands in invisible markup" bug was two of them disagreeing. + +import type { RevealMode } from "./types"; + +/** A selection range in document offsets, anchor/head order normalized. */ +export interface SelRange { + readonly from: number; + readonly to: number; +} + +/** + * True when [selFrom, selTo] touches [from, to], boundary inclusive. A caret + * immediately after **bold** still counts, because typing there is exactly + * when the eye needs the markup on screen. + */ +export function touches( + from: number, + to: number, + selFrom: number, + selTo: number, +): boolean { + return selFrom <= to && selTo >= from; +} + +/** + * Whether a construct is revealed (shows raw syntax) for the given + * selection ranges. "never" constructs stay folded; "span" constructs open + * when any selection range touches them, so multi-cursor edits are as + * truthful as single-caret ones. + */ +export function revealed( + reveal: RevealMode, + from: number, + to: number, + sel: readonly SelRange[], +): boolean { + if (reveal !== "span") return false; + for (const r of sel) { + if (touches(from, to, r.from, r.to)) return true; + } + return false; +} diff --git a/src/lib/editor/scanner.ts b/src/lib/editor/scanner.ts new file mode 100644 index 0000000..fc85fad --- /dev/null +++ b/src/lib/editor/scanner.ts @@ -0,0 +1,182 @@ +// The single syntax walk and the table it produces. +// +// ConstructScanner routes every syntax node to the spec that owns it and +// collects the emissions into a ConstructTable. The table is the one source +// of truth the whole kernel derives from: rendering (decorations), caret +// legality (atomic ranges), and pointer normalization (coverage queries) all +// read the same data, so they cannot disagree about what is hidden. +// +// Pure over EditorState: no view, no DOM, fully unit-testable. + +import { Decoration, type DecorationSet } from "@codemirror/view"; +import { RangeSet, type EditorState, type Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { revealed, type SelRange } from "./reveal"; +import type { ConstructSpec, Emit, RevealMode, ScanContext, TextSpec } from "./types"; + +/** A declared construct span. */ +export interface ConstructSpan { + readonly from: number; + readonly to: number; + readonly reveal: RevealMode; +} + +/** A markup range replaced while its owning construct is folded. */ +export interface HiddenRange { + readonly owner: number; + readonly from: number; + readonly to: number; + readonly deco: Decoration; + /** True when the replacement renders a widget (visible object) rather than nothing. */ + readonly widget: boolean; +} + +const HIDDEN = Decoration.replace({}); + +/** + * The scan result. Immutable; every query takes the selection as input, so + * one table serves every selection state between rescans. + */ +export class ConstructTable { + constructor( + private readonly spans: readonly ConstructSpan[], + /** Sorted by (from, to). */ + private readonly hides: readonly HiddenRange[], + private readonly always: readonly Range[], + ) {} + + /** Hidden ranges whose owning construct is folded for `sel`. */ + foldedHides(sel: readonly SelRange[]): HiddenRange[] { + const open = this.spans.map((s) => revealed(s.reveal, s.from, s.to, sel)); + return this.hides.filter((h) => !open[h.owner]); + } + + /** Everything the view should draw for `sel`: styling plus active folds. */ + decorations(sel: readonly SelRange[]): DecorationSet { + const ranges = [...this.always]; + for (const h of this.foldedHides(sel)) { + ranges.push(h.deco.range(h.from, h.to)); + } + return Decoration.set(ranges, true); + } + + /** + * The folded ranges as a RangeSet for EditorView.atomicRanges: cursor + * motion and deletion treat them as single objects. Revealed constructs + * drop out, so approaching markup opens it and then edits it char by char. + */ + atomicRanges(sel: readonly SelRange[]): RangeSet { + const folded = this.foldedHides(sel); + if (folded.length === 0) return RangeSet.empty; + return RangeSet.of( + folded.map((h) => h.deco.range(h.from, h.to)), + true, + ); + } + + /** + * True when every position in [from, to) is inside some folded hidden + * range for `sel`: i.e. the span is entirely invisible (or replaced) on + * screen. CaretGuard uses this to recognize clicks past the visible end + * of a line. + */ + allHiddenBetween(from: number, to: number, sel: readonly SelRange[]): boolean { + if (from >= to) return true; + let cover = from; + for (const h of this.foldedHides(sel)) { + if (h.to <= cover) continue; + if (h.from > cover) return false; + cover = h.to; + if (cover >= to) return true; + } + return cover >= to; + } +} + +/** + * Owns the one tree walk. Constructed once with the spec registry; `scan` + * is called per update by the kernel plugin (and directly by tests). + */ +export class ConstructScanner { + readonly #byName = new Map(); + readonly #textSpecs: readonly TextSpec[]; + + constructor(specs: readonly ConstructSpec[], textSpecs: readonly TextSpec[] = []) { + for (const spec of specs) { + for (const name of spec.nodes) { + if (this.#byName.has(name)) { + throw new Error(`Two construct specs claim syntax node "${name}"`); + } + this.#byName.set(name, spec); + } + } + this.#textSpecs = textSpecs; + } + + scan( + state: EditorState, + ranges: readonly SelRange[], + preview: boolean, + ): ConstructTable { + const spans: ConstructSpan[] = []; + const spanIds = new Map(); + const hides: HiddenRange[] = []; + const hideSeen = new Set(); + const always: Range[] = []; + const lineSeen = new Set(); + + const emit: Emit = { + construct(from, to, reveal) { + const key = `${from}:${to}:${reveal}`; + let id = spanIds.get(key); + if (id === undefined) { + id = spans.length; + spans.push({ from, to, reveal }); + spanIds.set(key, id); + } + return id; + }, + hide(owner, from, to, deco) { + if (!preview || to <= from) return; + const key = `${owner}:${from}:${to}`; + if (hideSeen.has(key)) return; + hideSeen.add(key); + hides.push({ + owner, + from, + to, + deco: deco ?? HIDDEN, + widget: deco?.spec.widget != null, + }); + }, + mark(from, to, deco) { + if (to > from) always.push(deco.range(from, to)); + }, + line(lineFrom, deco) { + if (lineSeen.has(lineFrom)) return; + lineSeen.add(lineFrom); + always.push(deco.range(lineFrom)); + }, + }; + + const cx: ScanContext = { state, preview }; + const tree = syntaxTree(state); + for (const { from, to } of ranges) { + tree.iterate({ + from, + to, + enter: (node) => { + const spec = this.#byName.get(node.name); + if (!spec) return; + return spec.enter(node, cx, emit); + }, + }); + for (const ts of this.#textSpecs) { + ts.scan(state.doc.sliceString(from, to), from, cx, emit); + } + } + + hides.sort((a, b) => a.from - b.from || a.to - b.to); + return new ConstructTable(spans, hides, always); + } +} diff --git a/src/lib/editor/tasks.test.ts b/src/lib/editor/tasks.test.ts new file mode 100644 index 0000000..985336e --- /dev/null +++ b/src/lib/editor/tasks.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { taskToggleChange, taskChecked } from "./tasks"; + +describe("taskToggleChange", () => { + it("checks an open task", () => { + expect(taskToggleChange("- [ ] ship it", 100)).toEqual({ + from: 103, + to: 104, + insert: "x", + }); + }); + + it("unchecks a done task", () => { + expect(taskToggleChange("- [x] shipped", 0)).toEqual({ + from: 3, + to: 4, + insert: " ", + }); + }); + + it("handles uppercase X", () => { + expect(taskToggleChange("- [X] done", 0)?.insert).toBe(" "); + }); + + it("handles indented and ordered task items", () => { + expect(taskToggleChange(" - [ ] nested", 0)).toEqual({ + from: 5, + to: 6, + insert: "x", + }); + expect(taskToggleChange("1. [ ] first", 0)).toEqual({ + from: 4, + to: 5, + insert: "x", + }); + }); + + it("returns null for non-task lines", () => { + expect(taskToggleChange("- plain bullet", 0)).toBeNull(); + expect(taskToggleChange("plain text [ ] not a task", 0)).toBeNull(); + expect(taskToggleChange("> [ ] quoted, not a list", 0)).toBeNull(); + }); +}); + +describe("taskChecked", () => { + it("reads the marker state", () => { + expect(taskChecked("[x]")).toBe(true); + expect(taskChecked("[X]")).toBe(true); + expect(taskChecked("[ ]")).toBe(false); + }); +}); diff --git a/src/lib/editor/tasks.ts b/src/lib/editor/tasks.ts new file mode 100644 index 0000000..2080603 --- /dev/null +++ b/src/lib/editor/tasks.ts @@ -0,0 +1,58 @@ +// GFM task toggling. The checkbox widget (constructs/task.ts) and the +// edit-mode marker click below both flow through taskToggleChange, so a +// toggle is always the same one-character edit: undoable, auto-saved, +// tag-safe. + +import { EditorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { previewModeField } from "./kernel"; + +/** + * The one-character change that flips a task marker on this line, or null if + * the line is not a task item. Pure for tests; `lineFrom` is the line's + * document offset. + */ +export function taskToggleChange( + lineText: string, + lineFrom: number, +): { from: number; to: number; insert: string } | null { + const m = lineText.match(/^(\s*(?:[-*+]|\d+\.)\s+\[)([ xX])\]/); + if (!m) return null; + const at = lineFrom + m[1].length; + return { from: at, to: at + 1, insert: m[2] === " " ? "x" : " " }; +} + +/** True when the marker on this task line is checked. */ +export function taskChecked(markerText: string): boolean { + return /\[[xX]\]/.test(markerText); +} + +/** + * Edit mode: clicking the raw [ ] / [x] marker toggles it, so the checkbox + * habit works no matter which mode the note is in. Preview clicks are owned + * by the checkbox widget itself. + */ +export function editModeTaskToggle(): Extension { + return EditorView.domEventHandlers({ + mousedown(e, view) { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return false; + } + if (view.state.field(previewModeField)) return false; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }); + if (pos === null) return false; + const tree = syntaxTree(view.state); + const node = tree.resolveInner(pos, 1); + if (node.name !== "TaskMarker" || pos < node.from || pos >= node.to) { + return false; + } + const line = view.state.doc.lineAt(pos); + const change = taskToggleChange(line.text, line.from); + if (!change) return false; + e.preventDefault(); + view.dispatch({ changes: change }); + return true; + }, + }); +} diff --git a/src/lib/editor/theme.ts b/src/lib/editor/theme.ts new file mode 100644 index 0000000..35f62c7 --- /dev/null +++ b/src/lib/editor/theme.ts @@ -0,0 +1,97 @@ +// Every kernel base theme in one place. Class names are unchanged from the +// retired per-feature files, so app.css overrides and user familiarity hold. +// (.cm-tag is styled in app.css; underline ownership lives here, not in the +// highlight spec, so the Links underline setting is the single source of +// truth in both modes.) + +import { EditorView } from "@codemirror/view"; + +export const kernelTheme = EditorView.baseTheme({ + // Lists + ".cm-wysiwyg-bullet, .cm-wysiwyg-number": { + color: "var(--text)", + marginRight: "0.35em", + userSelect: "none", + }, + // Blockquotes + ".cm-wysiwyg-blockquote": { + borderLeft: "3px solid var(--accent)", + paddingLeft: "12px", + color: "var(--text-secondary)", + fontStyle: "italic", + }, + // Code fences + ".cm-wysiwyg-codeblock": { + background: "var(--bg-sidebar)", + fontFamily: "var(--font-meta)", + fontSize: "0.9em", + paddingLeft: "10px", + paddingRight: "10px", + }, + ".cm-wysiwyg-codeinfo": { + color: "var(--text-tertiary)", + fontSize: "0.85em", + }, + // Horizontal rules + ".cm-wysiwyg-hr": { + display: "inline-block", + width: "100%", + height: "1px", + verticalAlign: "middle", + background: "var(--border)", + }, + // Tables + ".cm-wysiwyg-table": { + fontFamily: "var(--font-meta)", + fontSize: "0.9em", + }, + ".cm-wysiwyg-tablehead": { + fontWeight: "600", + }, + ".cm-wysiwyg-tabledelim": { + color: "var(--text-tertiary)", + }, + // Links + ".cm-link-clickable": { + cursor: "pointer", + }, + ".cm-link-ul-always": { + textDecoration: "underline", + }, + ".cm-link-ul-hover": { + textDecoration: "none", + }, + ".cm-link-ul-hover:hover": { + textDecoration: "underline", + }, + ".cm-link-ul-never": { + textDecoration: "none", + }, + ".cm-link-ext::after": { + content: "'↗'", + fontSize: "0.7em", + verticalAlign: "super", + marginLeft: "1px", + opacity: "0.75", + }, + // Tasks + ".cm-task-checkbox": { + width: "14px", + height: "14px", + margin: "0 6px 0 0", + verticalAlign: "middle", + accentColor: "var(--accent)", + cursor: "pointer", + }, + ".cm-task-done": { + textDecoration: "line-through", + color: "var(--text-tertiary)", + }, + // Images + ".cm-image-preview": { + maxWidth: "100%", + maxHeight: "420px", + borderRadius: "6px", + verticalAlign: "text-bottom", + }, +}); diff --git a/src/lib/editor/types.ts b/src/lib/editor/types.ts new file mode 100644 index 0000000..2314dd1 --- /dev/null +++ b/src/lib/editor/types.ts @@ -0,0 +1,72 @@ +// The construct model shared by every kernel module. +// +// A ConstructSpec declares what a markdown construct looks like; the scanner +// (scanner.ts) routes syntax nodes to specs and collects what they emit into +// a ConstructTable. Specs never touch the view, events, or other constructs. +// That single direction of data flow is what makes the kernel's guarantees +// hold by construction (see ARCHITECTURE.md). + +import type { Decoration } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; +import type { SyntaxNodeRef } from "@lezer/common"; + +/** + * When a construct shows its raw syntax. + * + * - "span": revealed while the selection touches the span, boundary + * inclusive. The WYSIWYG default: approach it and it opens, leave and it + * folds. + * - "never": stays rendered. Used for widget replacements (bullets, task + * checkboxes) that remain stable objects; their hidden ranges are atomic, + * so the caret treats them as single units instead of entering them. + */ +export type RevealMode = "span" | "never"; + +/** Read-only context handed to each spec during a scan. */ +export interface ScanContext { + readonly state: EditorState; + /** True when the Aa toolbar is closed and markdown chrome is rendered. */ + readonly preview: boolean; +} + +/** + * The declaration surface for specs. All positions are document offsets. + * Everything emitted is collected by the scanner; nothing renders directly. + */ +export interface Emit { + /** + * Declare a construct span and how it reveals. Deduplicated on + * (from, to, reveal) so several marks sharing a parent declare it once. + * Returns the construct id used to attach hidden ranges. + */ + construct(from: number, to: number, reveal: RevealMode): number; + /** + * Markup replaced while the owning construct is folded: hidden entirely, + * or swapped for `deco`'s widget. A no-op outside preview mode, so specs + * never need to gate hides themselves. + */ + hide(owner: number, from: number, to: number, deco?: Decoration): void; + /** A styling mark, active regardless of reveal state or mode. */ + mark(from: number, to: number, deco: Decoration): void; + /** + * A line decoration at `lineFrom`. Deduplicated per line, first emit + * wins, matching the retired wysiwyg layer's behavior for nested blocks. + */ + line(lineFrom: number, deco: Decoration): void; +} + +/** A tree-driven construct: the scanner routes syntax nodes by name. */ +export interface ConstructSpec { + /** Node names this spec owns. A name may belong to only one spec. */ + readonly nodes: readonly string[]; + /** + * Handle one node. Return false to skip its children, mirroring lezer's + * iterate contract; return true (or nothing) to descend. + */ + enter(node: SyntaxNodeRef, cx: ScanContext, emit: Emit): boolean | void; +} + +/** A text-driven construct (regex over visible text), e.g. #tags. */ +export interface TextSpec { + scan(text: string, offset: number, cx: ScanContext, emit: Emit): void; +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 0fae5dc..38e5b2b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,7 +1,7 @@ // Small presentation helpers shared by the library views. /** Today shows a time (e.g. "3:04 PM"); any other day shows a short date - * (e.g. "Jun 5"). Mirrors the previous inline helper in the library page. */ + * (e.g. "Jun 5"). */ export function formatDate(iso: string): string { const d = new Date(iso); const today = new Date(); diff --git a/src/lib/highlight.test.ts b/src/lib/highlight.test.ts new file mode 100644 index 0000000..b2d94ab --- /dev/null +++ b/src/lib/highlight.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { parseHighlightSegments, HIGHLIGHT_START, HIGHLIGHT_END } from "./highlight"; + +function mark(text: string): string { + return `${HIGHLIGHT_START}${text}${HIGHLIGHT_END}`; +} + +describe("parseHighlightSegments", () => { + it("marks a single hit inside surrounding plain text", () => { + const segments = parseHighlightSegments(`find your ${mark("passport")} today`); + expect(segments).toEqual([ + { text: "find your ", hit: false }, + { text: "passport", hit: true }, + { text: " today", hit: false }, + ]); + }); + + it("marks multiple separated hits", () => { + const segments = parseHighlightSegments(`${mark("passport")} and ${mark("tickets")}`); + expect(segments).toEqual([ + { text: "passport", hit: true }, + { text: " and ", hit: false }, + { text: "tickets", hit: true }, + ]); + }); + + it("marks adjacent hits with no gap between them", () => { + const segments = parseHighlightSegments(`${mark("pass")}${mark("port")}`); + expect(segments).toEqual([ + { text: "pass", hit: true }, + { text: "port", hit: true }, + ]); + }); + + it("degrades to stripped plain text when a start sentinel is never closed", () => { + const segments = parseHighlightSegments(`before ${HIGHLIGHT_START}unterminated`); + expect(segments).toEqual([{ text: "before unterminated", hit: false }]); + }); + + it("degrades to stripped plain text on a stray end sentinel with no matching start", () => { + const segments = parseHighlightSegments(`before ${HIGHLIGHT_END} after`); + expect(segments).toEqual([{ text: "before after", hit: false }]); + }); + + it("degrades to stripped plain text on nested sentinels", () => { + const segments = parseHighlightSegments( + `${HIGHLIGHT_START}outer ${HIGHLIGHT_START}inner${HIGHLIGHT_END} outer${HIGHLIGHT_END}`, + ); + expect(segments).toEqual([{ text: "outer inner outer", hit: false }]); + }); + + it("returns no segments for an empty string", () => { + expect(parseHighlightSegments("")).toEqual([]); + }); + + it("returns a single unhighlighted segment for a string with no markers", () => { + expect(parseHighlightSegments("plain excerpt, no matches")).toEqual([ + { text: "plain excerpt, no matches", hit: false }, + ]); + }); + + it("returns no segments for a marker-only string with nothing enclosed", () => { + expect(parseHighlightSegments(mark(""))).toEqual([]); + }); + + it("never crashes regardless of sentinel arrangement", () => { + const inputs = [ + HIGHLIGHT_START, + HIGHLIGHT_END, + HIGHLIGHT_START + HIGHLIGHT_START, + HIGHLIGHT_END + HIGHLIGHT_END, + HIGHLIGHT_END + HIGHLIGHT_START, + HIGHLIGHT_START + "a" + HIGHLIGHT_START + "b" + HIGHLIGHT_END, + ]; + for (const input of inputs) { + expect(() => parseHighlightSegments(input)).not.toThrow(); + } + }); +}); diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts new file mode 100644 index 0000000..185ffd5 --- /dev/null +++ b/src/lib/highlight.ts @@ -0,0 +1,68 @@ +// Splits a search excerpt/title into plain-text segments around the sentinel +// characters Store::search_notes wraps matched terms with (see store.rs). +// U+0001/U+0002 are control characters a user can never type, so their +// presence is unambiguous. This never touches HTML: callers render segments +// as text, never {@html}, and use `hit` to style the matched ones. + +export const HIGHLIGHT_START = "\u0001"; +export const HIGHLIGHT_END = "\u0002"; + +export interface HighlightSegment { + text: string; + hit: boolean; +} + +/** Parse a string that may contain sentinel-wrapped hits into segments. + * Malformed input (an unclosed start, a stray end, or nested markers) + * degrades to a single unhighlighted segment with sentinels stripped, + * rather than throwing or mis-rendering. */ +export function parseHighlightSegments(text: string): HighlightSegment[] { + if (!text) return []; + if (!text.includes(HIGHLIGHT_START) && !text.includes(HIGHLIGHT_END)) { + return [{ text, hit: false }]; + } + if (!isWellFormed(text)) { + return [{ text: stripSentinels(text), hit: false }]; + } + + const segments: HighlightSegment[] = []; + let buffer = ""; + let hit = false; + for (const ch of text) { + if (ch === HIGHLIGHT_START) { + if (buffer) segments.push({ text: buffer, hit }); + buffer = ""; + hit = true; + } else if (ch === HIGHLIGHT_END) { + if (buffer) segments.push({ text: buffer, hit }); + buffer = ""; + hit = false; + } else { + buffer += ch; + } + } + if (buffer) segments.push({ text: buffer, hit }); + return segments; +} + +/** True for properly paired, non-nested sentinels: every start is closed by + * an end before the next start, and nothing closes twice. */ +function isWellFormed(text: string): boolean { + let open = false; + for (const ch of text) { + if (ch === HIGHLIGHT_START) { + if (open) return false; + open = true; + } else if (ch === HIGHLIGHT_END) { + if (!open) return false; + open = false; + } + } + return !open; +} + +/** Remove the sentinels without any highlighting, for surfaces that render + * matched text plainly (the command palette). */ +export function stripSentinels(text: string): string { + return text.split(HIGHLIGHT_START).join("").split(HIGHLIGHT_END).join(""); +} diff --git a/src/lib/markdown-active.ts b/src/lib/markdown-active.ts index 0c2b5cb..443d90f 100644 --- a/src/lib/markdown-active.ts +++ b/src/lib/markdown-active.ts @@ -14,6 +14,7 @@ export interface ActiveMarks { code: boolean; quote: boolean; list: boolean; + task: boolean; } export const NO_MARKS: ActiveMarks = { @@ -23,6 +24,7 @@ export const NO_MARKS: ActiveMarks = { code: false, quote: false, list: false, + task: false, }; // Lezer-markdown node names → the toolbar mark they represent. @@ -48,6 +50,9 @@ function mark(name: string, marks: ActiveMarks): void { case "ListItem": marks.list = true; break; + case "Task": + marks.task = true; + break; } } diff --git a/src/lib/markdown-extensions.test.ts b/src/lib/markdown-extensions.test.ts new file mode 100644 index 0000000..49a7cc1 --- /dev/null +++ b/src/lib/markdown-extensions.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { highlightExtension } from "./markdown-extensions"; + +// Same parser setup as the editor. +function treeNames(doc: string): string[] { + const state = EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: [highlightExtension] }), + ], + }); + ensureSyntaxTree(state, doc.length, 5000); + const names: string[] = []; + syntaxTree(state).iterate({ + enter(n) { + names.push(n.name); + }, + }); + return names; +} + +describe("highlightExtension", () => { + it("parses ==text== into a Highlight node with marks", () => { + const names = treeNames("this is ==important== stuff"); + expect(names).toContain("Highlight"); + expect(names).toContain("HighlightMark"); + }); + + it("leaves single = alone", () => { + const names = treeNames("a = b and c =d"); + expect(names).not.toContain("Highlight"); + }); + + it("nests emphasis inside a highlight", () => { + const names = treeNames("==really **bold** point=="); + expect(names).toContain("Highlight"); + expect(names).toContain("StrongEmphasis"); + }); +}); diff --git a/src/lib/markdown-extensions.ts b/src/lib/markdown-extensions.ts new file mode 100644 index 0000000..f403682 --- /dev/null +++ b/src/lib/markdown-extensions.ts @@ -0,0 +1,34 @@ +// Custom lezer-markdown syntax beyond GFM. Currently one addition: +// ==highlight== (the marker pencil), which GFM does not define but every +// serious markdown note app supports. Parsed as a proper inline node so the +// WYSIWYG layer can hide the markers and the highlight style can tint the +// span - no regex over text. + +import type { MarkdownConfig } from "@lezer/markdown"; +import { Tag } from "@lezer/highlight"; + +/** Highlight content tag, styled in markdown-highlight.ts. */ +export const highlightTag = Tag.define(); + +const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" }; + +const EQ = 61; // "=" + +export const highlightExtension: MarkdownConfig = { + defineNodes: [ + { name: "Highlight", style: { "Highlight/...": highlightTag } }, + { name: "HighlightMark" }, + ], + parseInline: [ + { + name: "Highlight", + parse(cx, next, pos) { + if (next !== EQ || cx.char(pos + 1) !== EQ) return -1; + return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true); + }, + // Run alongside emphasis-style delimiters, before link resolution + // swallows the characters. + before: "Emphasis", + }, + ], +}; diff --git a/src/lib/markdown-format.ts b/src/lib/markdown-format.ts index 82f0774..e2a8e94 100644 --- a/src/lib/markdown-format.ts +++ b/src/lib/markdown-format.ts @@ -11,7 +11,15 @@ export interface Edit { selection: Sel; } -export type FormatKind = "bold" | "italic" | "strike" | "code" | "quote" | "list" | "link"; +export type FormatKind = + | "bold" + | "italic" + | "strike" + | "code" + | "quote" + | "list" + | "task" + | "link"; /** Map a toolbar/shortcut action to the corresponding document edit. */ export function formatEdit(doc: string, sel: Sel, kind: FormatKind): Edit { @@ -28,6 +36,8 @@ export function formatEdit(doc: string, sel: Sel, kind: FormatKind): Edit { return toggleLinePrefix(doc, sel, "> "); case "list": return toggleLinePrefix(doc, sel, "- "); + case "task": + return toggleLinePrefix(doc, sel, "- [ ] "); case "link": return insertLink(doc, sel); } diff --git a/src/lib/markdown-highlight.test.ts b/src/lib/markdown-highlight.test.ts index 0ce4558..bac9898 100644 --- a/src/lib/markdown-highlight.test.ts +++ b/src/lib/markdown-highlight.test.ts @@ -1,14 +1,19 @@ import { describe, it, expect } from "vitest"; -import { markdownHighlightSpec, HEADING_TAGS } from "./markdown-highlight"; +import { tags as t } from "@lezer/highlight"; +import { markdownHighlightSpec } from "./markdown-highlight"; describe("markdown highlight spec", () => { const styledTags = markdownHighlightSpec.flatMap((s) => Array.isArray(s.tag) ? s.tag : [s.tag], ); - it("never styles a markdown heading (# is reserved for tags)", () => { - for (const heading of HEADING_TAGS) { - expect(styledTags).not.toContain(heading); + // Headings render because `# Heading` (space) and `#tag` (no space) are + // disjoint: CommonMark requires the space for a heading, the tag + // highlighter requires its absence. If either side of that invariant + // changes, tags and headings collide - revisit both together. + it("styles headings with a visual scale", () => { + for (const heading of [t.heading1, t.heading2, t.heading3, t.heading]) { + expect(styledTags).toContain(heading); } }); diff --git a/src/lib/markdown-highlight.ts b/src/lib/markdown-highlight.ts index 576ac59..c26b327 100644 --- a/src/lib/markdown-highlight.ts +++ b/src/lib/markdown-highlight.ts @@ -1,31 +1,34 @@ // Inline markdown styling for the editor: markers stay visible, the text just -// looks structured. Headings are intentionally NOT styled - `#` is reserved for -// tags in InstantNotes, so `#title` stays a tag and is never rendered as a -// heading. The editor's tag highlighter styles `#tag` on top of this. +// looks structured. +// +// Headings and tags coexist without ambiguity: CommonMark only parses +// `# Heading` (with a space) as a heading, and the editor's tag highlighter +// only matches `#tag` (no space). So `#roadmap` stays a tag and `# Roadmap` +// renders as a title - two different gestures, two different meanings. import { HighlightStyle } from "@codemirror/language"; import { tags as t } from "@lezer/highlight"; - -// Markdown highlight tags we must never style, so a tag can't be mistaken for a -// heading. Pinned by a test against the spec below. -export const HEADING_TAGS = [ - t.heading, - t.heading1, - t.heading2, - t.heading3, - t.heading4, - t.heading5, - t.heading6, -]; +import { highlightTag } from "./markdown-extensions"; export const markdownHighlightSpec = [ + // heading1..3 get distinct scale; deeper levels share the generic heading + // weight (lezer tag hierarchy: headingN falls back to heading). + { tag: t.heading1, fontSize: "1.5em", fontWeight: "700" }, + { tag: t.heading2, fontSize: "1.3em", fontWeight: "650" }, + { tag: t.heading3, fontSize: "1.15em", fontWeight: "600" }, + { tag: t.heading, fontWeight: "600" }, { tag: t.strong, fontWeight: "600" }, { tag: t.emphasis, fontStyle: "italic" }, { tag: t.strikethrough, textDecoration: "line-through" }, { tag: t.monospace, fontFamily: "var(--font-meta)" }, { tag: t.quote, color: "var(--text-secondary)", fontStyle: "italic" }, - { tag: t.link, color: "var(--accent)", textDecoration: "underline" }, + // Underline is owned by the link-appearance setting (src/lib/editor), not + // baked in here, so "underline: never/hover" can actually win. + { tag: t.link, color: "var(--accent)" }, { tag: t.url, color: "var(--accent)" }, + // ==highlight== spans, parsed by the custom extension in + // markdown-extensions.ts. + { tag: highlightTag, backgroundColor: "var(--accent-soft)", borderRadius: "2px" }, ]; export const markdownHighlight = HighlightStyle.define(markdownHighlightSpec); diff --git a/src/lib/note-groups.test.ts b/src/lib/note-groups.test.ts new file mode 100644 index 0000000..0c9e28b --- /dev/null +++ b/src/lib/note-groups.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { groupNotes } from "./note-groups"; +import type { Note } from "$lib/api/types"; + +// Fixed local clock: mid-afternoon so day boundaries are unambiguous. +const NOW = new Date(2026, 6, 10, 15, 0, 0); // 2026-07-10 15:00 local + +function mkNote(id: string, updatedAt: Date, overrides: Partial = {}): Note { + return { + id, + title: `Note ${id}`, + body: "", + createdAt: updatedAt.toISOString(), + updatedAt: updatedAt.toISOString(), + isPinned: false, + isArchived: false, + isDeleted: false, + ...overrides, + }; +} + +function local(y: number, mo: number, d: number, h = 12): Date { + return new Date(y, mo - 1, d, h); +} + +describe("groupNotes", () => { + it("buckets across every boundary in list order", () => { + const notes = [ + mkNote("today", local(2026, 7, 10, 9)), + mkNote("yesterday", local(2026, 7, 9, 23)), + mkNote("week", local(2026, 7, 4)), + mkNote("month", local(2026, 6, 15)), + mkNote("this-year", local(2026, 3, 2)), + mkNote("last-year", local(2025, 11, 30)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual([ + "Today", + "Yesterday", + "Previous 7 Days", + "Previous 30 Days", + "March", + "2025", + ]); + expect(groups.map((g) => g.notes.length)).toEqual([1, 1, 1, 1, 1, 1]); + }); + + it("merges adjacent notes into one section", () => { + const notes = [ + mkNote("a", local(2026, 7, 10, 9)), + mkNote("b", local(2026, 7, 10, 8)), + mkNote("c", local(2026, 7, 9, 20)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => [g.label, g.notes.length])).toEqual([ + ["Today", 2], + ["Yesterday", 1], + ]); + }); + + it("floats pinned notes into their own leading section", () => { + const notes = [ + mkNote("pinned-old", local(2025, 2, 1), { isPinned: true }), + mkNote("recent", local(2026, 7, 10, 9)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Pinned", "Today"]); + }); + + it("ignores a leftover pin flag in the trash so recency order holds", () => { + const notes = [ + mkNote("t1", local(2026, 7, 10, 9), { isDeleted: true }), + mkNote("t2", local(2026, 7, 9, 9), { isDeleted: true, isPinned: true }), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday"]); + }); + + it("treats one minute past local midnight as Today, and just before as Yesterday", () => { + const notes = [ + mkNote("after", new Date(2026, 6, 10, 0, 1)), + mkNote("before", new Date(2026, 6, 9, 23, 59)), + ]; + const groups = groupNotes(notes, NOW); + expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday"]); + }); + + it("returns no groups for an empty list", () => { + expect(groupNotes([], NOW)).toEqual([]); + }); +}); diff --git a/src/lib/note-groups.ts b/src/lib/note-groups.ts new file mode 100644 index 0000000..4ba9d6a --- /dev/null +++ b/src/lib/note-groups.ts @@ -0,0 +1,50 @@ +// Time-bucketed sections for the note list (the macOS-native grouping: +// Pinned, Today, Yesterday, Previous 7 Days, Previous 30 Days, month names +// for this year, then plain years). Pure and clock-injected so tests pin +// every boundary. + +import type { Note } from "$lib/api/types"; + +export interface NoteGroup { + label: string; + notes: Note[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function bucketLabel(note: Note, now: Date): string { + // Pinned floats as its own section, except in the trash, where the list + // is plain recency order and a leftover pin flag must not reorder it. + if (note.isPinned && !note.isDeleted) return "Pinned"; + + const t = new Date(note.updatedAt); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + if (t.getTime() >= startOfToday.getTime()) return "Today"; + if (t.getTime() >= startOfToday.getTime() - DAY_MS) return "Yesterday"; + if (t.getTime() >= startOfToday.getTime() - 7 * DAY_MS) return "Previous 7 Days"; + if (t.getTime() >= startOfToday.getTime() - 30 * DAY_MS) return "Previous 30 Days"; + if (t.getFullYear() === now.getFullYear()) { + return t.toLocaleString(undefined, { month: "long" }); + } + return String(t.getFullYear()); +} + +/** + * Group a note list into labeled sections, preserving the incoming order. + * Adjacent notes with the same label share a section; the caller's sort + * (pinned first, then updatedAt desc) makes the labels monotonic, so each + * label appears exactly once. + */ +export function groupNotes(notes: Note[], now: Date): NoteGroup[] { + const groups: NoteGroup[] = []; + for (const note of notes) { + const label = bucketLabel(note, now); + const last = groups[groups.length - 1]; + if (last && last.label === label) { + last.notes.push(note); + } else { + groups.push({ label, notes: [note] }); + } + } + return groups; +} diff --git a/src/lib/palette-sections.test.ts b/src/lib/palette-sections.test.ts new file mode 100644 index 0000000..b964e63 --- /dev/null +++ b/src/lib/palette-sections.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + clampActive, + flattenRows, + moveActive, + rowDomId, + visibleSections, + type PaletteSection, +} from "./palette-sections"; + +interface Row { + id: string; +} + +const row = (id: string): Row => ({ id }); + +describe("visibleSections", () => { + it("drops sections with no rows", () => { + const sections: PaletteSection[] = [ + { label: "Commands", rows: [row("a")] }, + { label: "Notes", rows: [] }, + ]; + expect(visibleSections(sections).map((s) => s.label)).toEqual(["Commands"]); + }); + + it("keeps original order among the sections that remain", () => { + const sections: PaletteSection[] = [ + { label: "First", rows: [] }, + { label: "Second", rows: [row("a")] }, + { label: "Third", rows: [row("b")] }, + ]; + expect(visibleSections(sections).map((s) => s.label)).toEqual(["Second", "Third"]); + }); + + it("returns an empty array when every section is empty", () => { + const sections: PaletteSection[] = [ + { label: "Commands", rows: [] }, + { label: "Notes", rows: [] }, + ]; + expect(visibleSections(sections)).toEqual([]); + }); +}); + +describe("flattenRows", () => { + it("concatenates rows in section order", () => { + const sections: PaletteSection[] = [ + { label: "Commands", rows: [row("a"), row("b")] }, + { label: "Notes", rows: [row("c")] }, + ]; + expect(flattenRows(sections).map((r) => r.id)).toEqual(["a", "b", "c"]); + }); + + it("skips headers entirely: no placeholder rows for empty sections", () => { + const sections: PaletteSection[] = [ + { label: "Commands", rows: [row("a")] }, + { label: "Notes", rows: [] }, + { label: "Recent", rows: [row("b")] }, + ]; + expect(flattenRows(sections).map((r) => r.id)).toEqual(["a", "b"]); + }); + + it("is stable: flattening twice yields the same order", () => { + const sections: PaletteSection[] = [ + { label: "Commands", rows: [row("a"), row("b")] }, + { label: "Notes", rows: [row("c"), row("d")] }, + ]; + expect(flattenRows(sections)).toEqual(flattenRows(sections)); + }); +}); + +describe("moveActive", () => { + it("moves forward and backward within range", () => { + expect(moveActive(0, 1, 5)).toBe(1); + expect(moveActive(2, -1, 5)).toBe(1); + }); + + it("wraps from the last row to the first on ArrowDown", () => { + expect(moveActive(4, 1, 5)).toBe(0); + }); + + it("wraps from the first row to the last on ArrowUp", () => { + expect(moveActive(0, -1, 5)).toBe(4); + }); + + it("returns 0 for an empty list", () => { + expect(moveActive(0, 1, 0)).toBe(0); + expect(moveActive(0, -1, 0)).toBe(0); + }); +}); + +describe("clampActive", () => { + it("leaves an in-range index untouched", () => { + expect(clampActive(2, 5)).toBe(2); + }); + + it("pulls the index back to the new last row when the list shrinks", () => { + expect(clampActive(4, 2)).toBe(1); + }); + + it("never goes negative", () => { + expect(clampActive(-3, 5)).toBe(0); + }); + + it("returns 0 for an empty list", () => { + expect(clampActive(3, 0)).toBe(0); + }); +}); + +describe("rowDomId", () => { + it("prefixes the row id so it cannot collide with unrelated DOM ids", () => { + expect(rowDomId("note.new")).toBe("palette-row-note.new"); + expect(rowDomId("note:abc-123")).toBe("palette-row-note:abc-123"); + }); +}); diff --git a/src/lib/palette-sections.ts b/src/lib/palette-sections.ts new file mode 100644 index 0000000..3c84154 --- /dev/null +++ b/src/lib/palette-sections.ts @@ -0,0 +1,53 @@ +// Pure section-merging and keyboard-navigation math for the command palette. +// The palette groups its rows into labelled sections (Commands, Notes, Recent…) +// but keyboard/mouse selection moves through one flat, gap-free list. No store +// or Tauri imports here, so the index math is unit-testable without a DOM. + +/** Minimal shape a palette row must have: a stable, unique id for DOM/aria + * wiring. The palette itself decides what a row actually renders. */ +export interface PaletteRow { + id: string; +} + +export interface PaletteSection { + // Empty string renders no header (e.g. the top-level command list keeps its + // current headerless look). Presentational only, never part of navigation. + label: string; + rows: Row[]; +} + +/** Sections with at least one row, original order preserved. An empty section + * contributes neither a header nor any navigable rows. */ +export function visibleSections( + sections: PaletteSection[], +): PaletteSection[] { + return sections.filter((s) => s.rows.length > 0); +} + +/** All rows across sections, in section order. Call after visibleSections so + * flat indices line up with what is actually rendered. */ +export function flattenRows( + sections: PaletteSection[], +): Row[] { + return sections.flatMap((s) => s.rows); +} + +/** Move the active flat index by `delta`, wrapping at both ends. 0 for an + * empty list (there is nothing to select, but the index must stay valid). */ +export function moveActive(current: number, delta: number, length: number): number { + if (length <= 0) return 0; + return ((current + delta) % length + length) % length; +} + +/** Keep the active index in range as the row count shrinks, e.g. while a + * query narrows the results. Never adjusts a still-valid index. */ +export function clampActive(current: number, length: number): number { + if (length <= 0) return 0; + return Math.min(Math.max(current, 0), length - 1); +} + +/** DOM id for a row, shared by the row element (id) and the input's + * aria-activedescendant so assistive tech can track the active option. */ +export function rowDomId(rowId: string): string { + return `palette-row-${rowId}`; +} diff --git a/src/lib/reactive-collections.test.ts b/src/lib/reactive-collections.test.ts new file mode 100644 index 0000000..4d648fb --- /dev/null +++ b/src/lib/reactive-collections.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + withMapEntry, + withoutMapKeys, + withSetEntry, + withoutSetEntries, +} from "./reactive-collections"; + +describe("reactive-collections", () => { + it("withMapEntry returns a new map with the entry set, leaving the source untouched", () => { + const src = new Map([["a", 1]]); + const out = withMapEntry(src, "b", 2); + expect(out).not.toBe(src); + expect([...out]).toEqual([ + ["a", 1], + ["b", 2], + ]); + expect(src.has("b")).toBe(false); + }); + + it("withMapEntry overwrites an existing key", () => { + expect(withMapEntry(new Map([["a", 1]]), "a", 9).get("a")).toBe(9); + }); + + it("withoutMapKeys removes every given key without touching the source", () => { + const src = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + const out = withoutMapKeys(src, ["a", "c", "missing"]); + expect([...out]).toEqual([["b", 2]]); + expect(src.size).toBe(3); + }); + + it("withSetEntry adds a value into a fresh set", () => { + const src = new Set(["a"]); + const out = withSetEntry(src, "b"); + expect(out).not.toBe(src); + expect([...out]).toEqual(["a", "b"]); + expect(src.has("b")).toBe(false); + }); + + it("withoutSetEntries removes every given value", () => { + const src = new Set(["a", "b", "c"]); + expect([...withoutSetEntries(src, ["a", "c"])]).toEqual(["b"]); + expect(src.size).toBe(3); + }); +}); diff --git a/src/lib/reactive-collections.ts b/src/lib/reactive-collections.ts new file mode 100644 index 0000000..8c6b2c2 --- /dev/null +++ b/src/lib/reactive-collections.ts @@ -0,0 +1,34 @@ +// Svelte 5 runes track reassignment of a $state Map or Set, not in-place +// mutation. These return a fresh collection so `store.x = withMapEntry(store.x, +// ...)` triggers an update, keeping the copy-then-mutate boilerplate (and the +// easy-to-forget reassignment) out of the call sites. + +export function withMapEntry( + map: ReadonlyMap, + key: K, + value: V, +): Map { + return new Map(map).set(key, value); +} + +export function withoutMapKeys( + map: ReadonlyMap, + keys: Iterable, +): Map { + const next = new Map(map); + for (const key of keys) next.delete(key); + return next; +} + +export function withSetEntry(set: ReadonlySet, value: T): Set { + return new Set(set).add(value); +} + +export function withoutSetEntries( + set: ReadonlySet, + values: Iterable, +): Set { + const next = new Set(set); + for (const value of values) next.delete(value); + return next; +} diff --git a/src/lib/stores/confirm.svelte.ts b/src/lib/stores/confirm.svelte.ts new file mode 100644 index 0000000..8a5d12a --- /dev/null +++ b/src/lib/stores/confirm.svelte.ts @@ -0,0 +1,67 @@ +// Promise-based confirm dialog for irreversible actions, replacing every +// window.confirm in the app. One host (ConfirmDialog.svelte) is mounted once +// in +page.svelte; call sites just `await confirmDialog.ask(...)`. + +export interface ConfirmOptions { + title: string; + body?: string; + confirmLabel?: string; + cancelLabel?: string; + tone?: "danger" | "neutral"; +} + +export interface ConfirmRequest { + title: string; + body: string; + confirmLabel: string; + cancelLabel: string; + tone: "danger" | "neutral"; +} + +class ConfirmDialogStore { + request = $state(null); + + #resolve: ((ok: boolean) => void) | null = null; + // Focus returns here once the dialog closes, so a keyboard user lands back + // where they were rather than at the top of the document. + #invoker: HTMLElement | null = null; + + ask(options: ConfirmOptions): Promise { + // Only one confirm can be open at a time; a second call while one is + // pending settles the first as cancelled rather than stacking dialogs. + this.#settle(false); + this.#invoker = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + return new Promise((resolve) => { + this.#resolve = resolve; + this.request = { + title: options.title, + body: options.body ?? "", + confirmLabel: options.confirmLabel ?? "Confirm", + cancelLabel: options.cancelLabel ?? "Cancel", + tone: options.tone ?? "neutral", + }; + }); + } + + confirm(): void { + this.#settle(true); + } + + cancel(): void { + this.#settle(false); + } + + #settle(result: boolean): void { + if (!this.#resolve) return; + this.request = null; + const resolve = this.#resolve; + const invoker = this.#invoker; + this.#resolve = null; + this.#invoker = null; + resolve(result); + queueMicrotask(() => invoker?.focus()); + } +} + +export const confirmDialog = new ConfirmDialogStore(); diff --git a/src/lib/stores/library.svelte.test.ts b/src/lib/stores/library.svelte.test.ts new file mode 100644 index 0000000..bbb2678 --- /dev/null +++ b/src/lib/stores/library.svelte.test.ts @@ -0,0 +1,805 @@ +// Save queue, race token, and quit-flush tests for the library store. These +// behaviors shipped untested (July 2026 frontend audit) and each test below +// is built to fail if the guarded behavior regresses. +// +// $lib/api/client and @tauri-apps/api/event are mocked; timers are fake +// throughout so debounce/retry timing is deterministic. Because `library` is +// a module-level singleton, every test loads a fresh copy of the module via +// vi.resetModules() + dynamic import so state never bleeds between tests. +// (vi.mock's factory itself is not re-run by resetModules, so the imported +// mock functions below keep stable identity across the whole file; only the +// store's own module -- and therefore its state -- is fresh per test.) + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + addNoteToWorkspace, + ApiError, + createNote, + deleteWorkspace, + destroyNotes, + getNote, + getOrCreateWorkspace, + listNotes, + listTags, + listWorkspaces, + listWorkspaceTags, + permanentlyDeleteNote, + renameWorkspace, + searchNotes, + softDeleteNote, + softDeleteNotes, + tagsForNote, + updateNote, + workspacesForNote, +} from "$lib/api/client"; +import { listen } from "@tauri-apps/api/event"; +import type { + Note, + SearchResult, + TagWithCount, + WorkspaceWithCount, +} from "$lib/api/types"; + +vi.mock("$lib/api/client", () => { + class ApiError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.name = "ApiError"; + this.code = code; + } + } + return { + ApiError, + createNote: vi.fn(), + getNote: vi.fn(), + updateNote: vi.fn(), + softDeleteNote: vi.fn(), + softDeleteNotes: vi.fn(), + restoreNote: vi.fn(), + restoreNotes: vi.fn(), + setNotesFlags: vi.fn(), + permanentlyDeleteNote: vi.fn(), + destroyNotes: vi.fn(), + listNotes: vi.fn(), + searchNotes: vi.fn(), + listTags: vi.fn(), + listWorkspaces: vi.fn(), + getOrCreateWorkspace: vi.fn(), + renameWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + listWorkspaceTags: vi.fn(), + addNoteToWorkspace: vi.fn(), + removeNoteFromWorkspace: vi.fn(), + workspacesForNote: vi.fn(), + addTagToNote: vi.fn(), + removeTagFromNote: vi.fn(), + tagsForNote: vi.fn(), + }; +}); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +const mockCreateNote = vi.mocked(createNote); +const mockGetNote = vi.mocked(getNote); +const mockUpdateNote = vi.mocked(updateNote); +const mockListNotes = vi.mocked(listNotes); +const mockSearchNotes = vi.mocked(searchNotes); +const mockListTags = vi.mocked(listTags); +const mockListWorkspaces = vi.mocked(listWorkspaces); +const mockTagsForNote = vi.mocked(tagsForNote); +const mockWorkspacesForNote = vi.mocked(workspacesForNote); +const mockPermanentlyDeleteNote = vi.mocked(permanentlyDeleteNote); +const mockDestroyNotes = vi.mocked(destroyNotes); +const mockSoftDeleteNote = vi.mocked(softDeleteNote); +const mockSoftDeleteNotes = vi.mocked(softDeleteNotes); +const mockDeleteWorkspace = vi.mocked(deleteWorkspace); +const mockRenameWorkspace = vi.mocked(renameWorkspace); +const mockGetOrCreateWorkspace = vi.mocked(getOrCreateWorkspace); +const mockAddNoteToWorkspace = vi.mocked(addNoteToWorkspace); +const mockListWorkspaceTags = vi.mocked(listWorkspaceTags); +const mockListen = vi.mocked(listen); + +function mkNote(id: string, overrides: Partial = {}): Note { + return { + id, + title: `Note ${id}`, + body: "", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + isPinned: false, + isArchived: false, + isDeleted: false, + ...overrides, + }; +} + +function mkSearchResult(id: string): SearchResult { + return { + noteId: id, + title: `Note ${id}`, + excerpt: "", + score: 1, + updatedAt: "2026-01-01T00:00:00Z", + }; +} + +/** A promise plus its resolve/reject, so races can be driven by hand. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Fresh module graph so the singleton store instance starts clean. */ +async function load() { + const mod = await import("$lib/stores/library.svelte"); + return mod.library; +} + +async function selectNote( + library: Awaited>, + id: string, + overrides: Partial = {}, +) { + mockGetNote.mockResolvedValueOnce(mkNote(id, overrides)); + await library.select(id); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + + mockCreateNote.mockReset(); + mockGetNote.mockReset(); + mockUpdateNote.mockReset(); + mockListNotes.mockReset().mockResolvedValue([]); + mockSearchNotes.mockReset().mockResolvedValue([]); + mockListTags.mockReset().mockResolvedValue([]); + mockListWorkspaces.mockReset().mockResolvedValue([]); + mockTagsForNote.mockReset().mockResolvedValue([]); + mockWorkspacesForNote.mockReset().mockResolvedValue([]); + mockPermanentlyDeleteNote.mockReset(); + mockDestroyNotes.mockReset(); + mockDestroyNotes.mockResolvedValue(undefined); + mockSoftDeleteNote.mockReset(); + mockSoftDeleteNotes.mockReset(); + mockSoftDeleteNotes.mockResolvedValue(undefined); + mockDeleteWorkspace.mockReset(); + mockRenameWorkspace.mockReset(); + mockGetOrCreateWorkspace.mockReset(); + mockAddNoteToWorkspace.mockReset(); + mockListWorkspaceTags.mockReset().mockResolvedValue([]); + mockListen.mockReset().mockResolvedValue(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("save queue", () => { + it("queues the edit and reports saving until the debounced write settles", async () => { + const library = await load(); + await selectNote(library, "n1"); + + const write = deferred(); + mockUpdateNote.mockReturnValueOnce(write.promise); + + library.editBody("new body"); + // #unsaved is set synchronously in editBody, before the debounce fires. + expect(library.saveState).toBe("saving"); + expect(mockUpdateNote).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "new body" }); + // Write still in flight: must not claim "saved" over unpersisted data. + expect(library.saveState).toBe("saving"); + + write.resolve(mkNote("n1", { body: "new body" })); + await vi.advanceTimersByTimeAsync(0); + expect(library.saveState).toBe("saved"); + }); + + it("retries once after ~2s and clears saving/failed state on a successful retry", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote + .mockRejectedValueOnce(new ApiError("STORAGE_ERROR", "locked")) + .mockResolvedValueOnce(mkNote("n1", { body: "retry body" })); + + library.editBody("retry body"); + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + // First failure alone must not surface as "failed" -- it's still + // waiting out the retry backoff. + expect(library.saveState).toBe("saving"); + expect(library.error).toBeNull(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(2); + expect(library.saveState).toBe("saved"); + }); + + it("moves the note to failed after a second consecutive failure", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue( + new ApiError("STORAGE_ERROR", "disk full"), + ); + + library.editBody("doomed"); + await vi.advanceTimersByTimeAsync(400); + expect(library.saveState).toBe("saving"); + expect(library.error).toBeNull(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(2); + expect(library.saveState).toBe("failed"); + expect(library.error).toBe( + "Your note couldn't be saved. Please try again.", + ); + }); +}); + +describe("flushPendingEdits", () => { + it("flushes the debounce immediately, without waiting for the 400ms window", async () => { + const library = await load(); + await selectNote(library, "n1"); + + const write = deferred(); + mockUpdateNote.mockReturnValueOnce(write.promise); + + library.editBody("flush me"); + const flushed = library.flushPendingEdits(); + // The write must fire synchronously off the flush, not off the timer. + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "flush me" }); + + write.resolve(mkNote("n1", { body: "flush me" })); + await flushed; + + expect(library.saveState).toBe("saved"); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("leaves a failed note in #failed rather than dropping the edit", async () => { + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + + library.editBody("will fail"); + await library.flushPendingEdits(); + + // Queued edit survives the failure: saveState is "failed", not "saved", + // and a later flush would still have something to retry (proven by the + // note staying selected/dirty rather than the state resetting to idle). + expect(library.saveState).toBe("failed"); + expect(library.error).toBe( + "Your note couldn't be saved. Please try again.", + ); + }); + + it("fixed: a flush of one dirty note performs exactly one write attempt, and no timer survives once it resolves", async () => { + // flushPendingEdits used to call #saveBody.flush(), which ran the + // retry-enabled callback (canRetry=true) and scheduled a hidden 2s + // setTimeout retry on failure, *in addition to* flushPendingEdits' own + // explicit no-retry re-attempt -- two immediate writes plus a stray + // third write after the flush had already resolved. flushPendingEdits + // now cancels the debounce outright and performs its own single + // no-retry persist, so a failing flush writes exactly once and leaves + // no retry timer behind. + const library = await load(); + await selectNote(library, "n1"); + + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + + library.editBody("stray retry"); + await library.flushPendingEdits(); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + expect(library.saveState).toBe("failed"); + + // No retry timer was scheduled by the no-retry flush path, so nothing + // fires after the old 2s retry window elapses. + await vi.advanceTimersByTimeAsync(2000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); +}); + +describe("destroy paths drop queued edits (regression: fixed 2026-07-08)", () => { + it("bulkDestroy cancels the pending debounce so no write is ever attempted", async () => { + const library = await load(); + await selectNote(library, "n1"); + + library.editBody("about to be destroyed"); + await library.bulkDestroy(); + + // Past debounce (400ms) and past the retry window (2000ms): if the + // queued edit were not dropped, updateNote would fire here. + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockDestroyNotes).toHaveBeenCalledWith(["n1"], true); + }); + + it("emptyTrash cancels queued edits for every trashed note before destroying them", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockListNotes.mockResolvedValueOnce([mkNote("n1"), mkNote("n2")]); + + library.editBody("in the trash"); + await library.emptyTrash(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockDestroyNotes).toHaveBeenCalledWith(["n1", "n2"], true); + }); + + it("destroySelected cancels the open note's queued edit", async () => { + const library = await load(); + await selectNote(library, "n1"); + + library.editBody("open note, about to be destroyed"); + await library.destroySelected(); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(mockDestroyNotes).toHaveBeenCalledWith(["n1"], true); + }); + + it("control: without a destroy, the same queued edit does reach updateNote", async () => { + // Sanity check for the three tests above: proves the fake-timer harness + // really does drive the debounce through to a write when nothing + // intervenes, so "updateNote not called" above is a meaningful signal + // and not an artifact of timers never firing. + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "kept" })); + + library.editBody("kept"); + await vi.advanceTimersByTimeAsync(400); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "kept" }); + }); +}); + +describe("soft delete flushes queued edits (Undo restores the last keystrokes)", () => { + it("deleteSelected persists the pending edit before trashing the note", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "last keystrokes" })); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + + library.editBody("last keystrokes"); + await library.deleteSelected(); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "last keystrokes", + }); + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + // The write must land before the trash, or a restore loses the edit. + const write = mockUpdateNote.mock.invocationCallOrder[0]; + const trash = mockSoftDeleteNote.mock.invocationCallOrder[0]; + expect(write).toBeLessThan(trash); + // Nothing further fires later: no leftover debounce, no retry timer. + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("bulkDelete persists pending edits for the selection before trashing", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue( + mkNote("n1", { body: "unsaved bulk edit" }), + ); + mockSoftDeleteNotes.mockResolvedValue(undefined); + + library.editBody("unsaved bulk edit"); + await library.bulkDelete(); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "unsaved bulk edit", + }); + expect(mockSoftDeleteNotes).toHaveBeenCalledWith(["n1"]); + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); + + it("a failed pre-trash flush still trashes the note and surfaces the error", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockRejectedValue( + new ApiError("STORAGE_ERROR", "disk full"), + ); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + + library.editBody("doomed edit"); + await library.deleteSelected(); + + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + expect(library.error).toBeTruthy(); + // The failed edit is dropped with the trashed note; no retry fires later. + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).toHaveBeenCalledTimes(1); + }); +}); + +describe("refresh race token", () => { + it("a slow older list refresh cannot clobber a newer one", async () => { + const library = await load(); + const older = deferred(); + const newer = deferred(); + mockListNotes + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + + const p1 = library.refresh(); + const p2 = library.refresh(); + + newer.resolve([mkNote("newer")]); + await p2; + expect(library.notes.map((n) => n.id)).toEqual(["newer"]); + + older.resolve([mkNote("older")]); + await p1; + // The stale response must be discarded, not applied after the fact. + expect(library.notes.map((n) => n.id)).toEqual(["newer"]); + }); + + it("a slow older search refresh cannot clobber a newer one", async () => { + const library = await load(); + library.setSearch("q"); + await vi.advanceTimersByTimeAsync(0); // let the immediate search-text set land; refresh below is called directly + + const older = deferred(); + const newer = deferred(); + mockSearchNotes + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + + const p1 = library.refresh(); + const p2 = library.refresh(); + + newer.resolve([mkSearchResult("newer")]); + await p2; + expect(library.searchResults?.map((r) => r.noteId)).toEqual(["newer"]); + + older.resolve([mkSearchResult("older")]); + await p1; + expect(library.searchResults?.map((r) => r.noteId)).toEqual(["newer"]); + }); +}); + +describe("search debounce", () => { + it("collapses rapid setSearch calls into a single query for the final text", async () => { + const library = await load(); + + library.setSearch("a"); + await vi.advanceTimersByTimeAsync(50); + library.setSearch("ab"); + await vi.advanceTimersByTimeAsync(50); + library.setSearch("abc"); + expect(mockSearchNotes).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(150); + expect(mockSearchNotes).toHaveBeenCalledTimes(1); + expect(mockSearchNotes).toHaveBeenCalledWith("abc"); + }); + + it("clearing the search text cancels the debounce and refreshes immediately", async () => { + const library = await load(); + + library.setSearch("something"); + expect(mockSearchNotes).not.toHaveBeenCalled(); + + library.setSearch(""); + // Immediate: listNotes fires synchronously off the clear, not gated by + // the 150ms debounce. + expect(mockListNotes).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(200); + // The canceled debounce must never fire the stale query. + expect(mockSearchNotes).not.toHaveBeenCalled(); + }); +}); + +describe("init ordering", () => { + it("registers listeners before the first fetch and waits for them to resolve", async () => { + const library = await load(); + const gate = deferred<() => void>(); + mockListen.mockImplementation(() => gate.promise); + + const initPromise = library.init(); + + expect(mockListen.mock.calls.map((c) => c[0])).toEqual([ + "notes:changed", + "tags:changed", + "workspaces:changed", + ]); + expect(mockListNotes).not.toHaveBeenCalled(); + expect(mockListTags).not.toHaveBeenCalled(); + expect(mockListWorkspaces).not.toHaveBeenCalled(); + + gate.resolve(() => {}); + await initPromise; + + // Two listNotes calls: the visible list and the revisit count. + expect(mockListNotes).toHaveBeenCalledTimes(2); + expect(mockListTags).toHaveBeenCalledTimes(1); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("guards against double invocation: a second concurrent call does no extra work", async () => { + const library = await load(); + + const p1 = library.init(); + const p2 = library.init(); + await Promise.all([p1, p2]); + + expect(mockListen).toHaveBeenCalledTimes(3); + // Two listNotes calls: the visible list and the revisit count. + expect(mockListNotes).toHaveBeenCalledTimes(2); + expect(mockListTags).toHaveBeenCalledTimes(1); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("wires the notes:changed listener to a debounced refresh", async () => { + const library = await load(); + await library.init(); + mockListNotes.mockClear(); + + const handler = mockListen.mock.calls.find( + (c) => c[0] === "notes:changed", + )?.[1] as (() => void) | undefined; + expect(handler).toBeTypeOf("function"); + handler!(); + + expect(mockListNotes).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(50); + // The debounce window releases both the list and the revisit count. + expect(mockListNotes).toHaveBeenCalledTimes(2); + }); +}); + +// ---- Spaces rework: scoped tag chips + undo-able workspace delete ---- + +function mkTagWithCount(id: string, name: string): TagWithCount { + return { + id, + name, + color: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + usageCount: 1, + }; +} + +function mkWorkspace(id: string, name: string): WorkspaceWithCount { + return { + id, + name, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + noteCount: 0, + }; +} + +describe("scoped tag filter (chips inside a workspace)", () => { + it("composes with the workspace filter, toggles off, and resets on switch", async () => { + const library = await load(); + mockListWorkspaceTags.mockResolvedValue([ + mkTagWithCount("t-school", "school"), + ]); + + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(mockListNotes).toHaveBeenLastCalledWith({ + workspaceId: "ws1", + tagIds: ["t-school"], + }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + library.selectWorkspace("ws2"); + expect(library.scopedTagId).toBeNull(); + }); + + it("is inert outside a workspace and never leaks into the global tag filter", async () => { + const library = await load(); + library.toggleScopedTag("t-anything"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + + mockListWorkspaceTags.mockResolvedValue([mkTagWithCount("t-x", "x")]); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + library.toggleScopedTag("t-x"); + await vi.advanceTimersByTimeAsync(0); + + library.setTagFilter("t-global"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ tagIds: ["t-global"] }); + }); + + it("drops a scoped tag that vanished from the workspace's visible notes", async () => { + const library = await load(); + mockListWorkspaceTags.mockResolvedValue([ + mkTagWithCount("t-school", "school"), + ]); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + library.toggleScopedTag("t-school"); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBe("t-school"); + + // The last #school note was edited away; the chip data comes back empty. + mockListWorkspaceTags.mockResolvedValue([]); + await library.refresh(); + await vi.advanceTimersByTimeAsync(0); + expect(library.scopedTagId).toBeNull(); + expect(mockListNotes).toHaveBeenLastCalledWith({ workspaceId: "ws1" }); + }); +}); + +describe("workspace delete with undo", () => { + it("deletes immediately and the toast's Undo re-adds every member id", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Movies")]); + await library.refreshWorkspaces(); + mockDeleteWorkspace.mockResolvedValue(["n1", "n2", "n3"]); + + await library.removeWorkspace("ws1"); + expect(mockDeleteWorkspace).toHaveBeenCalledWith("ws1"); + + const { toasts } = await import("$lib/stores/toasts.svelte"); + expect(toasts.items).toHaveLength(1); + expect(toasts.items[0].message).toBe('Deleted "Movies" - notes are kept'); + expect(toasts.items[0].action?.label).toBe("Undo"); + + mockGetOrCreateWorkspace.mockResolvedValue(mkWorkspace("ws-new", "Movies")); + mockAddNoteToWorkspace.mockResolvedValue(undefined); + toasts.activate(toasts.items[0].id); + await vi.advanceTimersByTimeAsync(0); + + expect(mockGetOrCreateWorkspace).toHaveBeenCalledWith("Movies"); + for (const id of ["n1", "n2", "n3"]) { + expect(mockAddNoteToWorkspace).toHaveBeenCalledWith(id, "ws-new"); + } + }); + + it("deleting the active workspace lands the view in All Notes", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Doomed")]); + await library.refreshWorkspaces(); + library.selectWorkspace("ws1"); + await vi.advanceTimersByTimeAsync(0); + + mockListWorkspaces.mockResolvedValue([]); + mockDeleteWorkspace.mockResolvedValue([]); + await library.removeWorkspace("ws1"); + expect(library.activeWorkspaceId).toBeNull(); + expect(library.scopedTagId).toBeNull(); + }); + + it("a partial undo (a member was destroyed meanwhile) reports what it restored", async () => { + const library = await load(); + mockListWorkspaces.mockResolvedValue([mkWorkspace("ws1", "Movies")]); + await library.refreshWorkspaces(); + mockDeleteWorkspace.mockResolvedValue(["n1", "n2"]); + await library.removeWorkspace("ws1"); + + const { toasts } = await import("$lib/stores/toasts.svelte"); + mockGetOrCreateWorkspace.mockResolvedValue(mkWorkspace("ws-new", "Movies")); + mockAddNoteToWorkspace + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new ApiError("NOT_FOUND", "note n2 not found")); + toasts.activate(toasts.items[0].id); + await vi.advanceTimersByTimeAsync(0); + + expect(toasts.items).toHaveLength(1); + expect(toasts.items[0].message).toBe( + 'Restored "Movies" without 1 of 2 notes.', + ); + }); +}); + +describe("workspace rename", () => { + it("returns ok and refreshes the list on success", async () => { + const library = await load(); + mockRenameWorkspace.mockResolvedValue(mkWorkspace("ws1", "Gamma")); + mockListWorkspaces.mockClear(); + const result = await library.renameWorkspace("ws1", "Gamma"); + expect(result).toEqual({ ok: true }); + expect(mockRenameWorkspace).toHaveBeenCalledWith("ws1", "Gamma"); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + }); + + it("surfaces a duplicate name as an inline error, not a thrown error", async () => { + const library = await load(); + mockRenameWorkspace.mockRejectedValue(new ApiError("CONFLICT", "exists")); + const result = await library.renameWorkspace("ws1", "Beta"); + expect(result).toEqual({ + ok: false, + message: "That name is already in use.", + }); + }); +}); + +describe("revisit mode (open-loop resurfacing)", () => { + it("filters to never-opened captures older than the window, oldest first", async () => { + const library = await load(); + library.selectRevisit(); + await vi.advanceTimersByTimeAsync(0); + const filter = mockListNotes.mock.lastCall?.[0]; + expect(filter).toMatchObject({ + neverOpened: true, + sortBy: "createdAt", + sortOrder: "asc", + }); + expect(typeof filter?.createdBefore).toBe("string"); + }); + + it("keeps the count in lockstep, and opening a note burns it down live", async () => { + const library = await load(); + mockListNotes.mockResolvedValue([mkNote("n1"), mkNote("n2")]); + library.selectRevisit(); + await vi.advanceTimersByTimeAsync(0); + expect(library.revisitCount).toBe(2); + + // getNote's touch releases n1; the store re-queries on open because the + // backend emits no change event for a touch. + mockListNotes.mockResolvedValue([mkNote("n2")]); + await selectNote(library, "n1"); + await vi.advanceTimersByTimeAsync(0); + expect(library.revisitCount).toBe(1); + expect(library.notes.map((n) => n.id)).toEqual(["n2"]); + }); + + it("creating a note exits revisit mode, like it exits trash", async () => { + const library = await load(); + mockCreateNote.mockResolvedValue(mkNote("new1")); + mockGetNote.mockResolvedValue(mkNote("new1")); + library.selectRevisit(); + await library.newNote(); + // A brand-new note can never match the Revisit filter; staying in the + // mode would hide the note the user just asked for. + expect(library.revisitMode).toBe(false); + await vi.advanceTimersByTimeAsync(0); + }); + + it("leaves revisit mode when any other view is selected", async () => { + const library = await load(); + library.selectRevisit(); + expect(library.revisitMode).toBe(true); + library.selectWorkspace("ws1"); + expect(library.revisitMode).toBe(false); + + library.selectRevisit(); + library.setTagFilter("t1"); + expect(library.revisitMode).toBe(false); + + library.selectRevisit(); + library.setStatusFilter("archived"); + expect(library.revisitMode).toBe(false); + await vi.advanceTimersByTimeAsync(0); + }); +}); diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 49d39ca..33fe667 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -12,12 +12,17 @@ import { listNotes, listTags, listWorkspaces, - permanentlyDeleteNote, + listWorkspaceTags, removeNoteFromWorkspace, removeTagFromNote, + renameWorkspace, restoreNote, searchNotes, softDeleteNote, + softDeleteNotes, + restoreNotes, + setNotesFlags, + destroyNotes as destroyNotesCmd, tagsForNote, updateNote, workspacesForNote, @@ -33,17 +38,40 @@ import type { } from "$lib/api/types"; import { debounce } from "$lib/debounce"; import { friendlyMessage } from "$lib/errors"; -import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; +import { SaveQueue, type SaveState } from "$lib/stores/library/save-queue.svelte"; +import { SelectionModel } from "$lib/stores/library/selection.svelte"; +import { toasts } from "$lib/stores/toasts.svelte"; import { listen } from "@tauri-apps/api/event"; +export type { SaveState }; + // Archived and trash live behind a list filter in All Notes, not as // top-level sections (two-section library: All Notes and Workspaces). export type StatusFilter = "active" | "archived" | "trash"; +// Debounce for search-text refreshes only, so a query runs per pause rather +// than per keystroke; filter clicks and change events stay immediate. +const SEARCH_DEBOUNCE_MS = 150; + +// A capture-born note that nobody has opened within this window is an open +// loop worth resurfacing. Newer captures aren't nagged about: they're often +// still in the user's head, and Revisit must never feel like a task manager. +const REVISIT_AFTER_MS = 3 * 24 * 60 * 60 * 1000; + class LibraryStore { statusFilter = $state("active"); activeWorkspaceId = $state(null); activeTagId = $state(null); + // Tag filter applied within the active workspace (the note list's chip + // row). Composes with activeWorkspaceId; the global activeTagId replaces + // the workspace instead. + scopedTagId = $state(null); + // Tags carried by the active workspace's visible notes; drives the chips. + workspaceTags = $state([]); + // Revisit: capture-born notes never opened in the library. The count keeps + // the sidebar entry honest (hidden at zero); the mode filters the list. + revisitMode = $state(false); + revisitCount = $state(0); searchText = $state(""); notes = $state([]); searchResults = $state(null); @@ -52,55 +80,124 @@ class LibraryStore { selected = $state(null); selectedTags = $state([]); selectedWorkspaces = $state([]); - // Ids checked for bulk actions. Holds the open note's id on a plain click; - // grows via cmd-click / shift-click. Size > 1 swaps the editor for the - // bulk-actions panel. - multiSelected = $state>(new Set()); error = $state(null); - loading = $state(false); - // True while a body edit is queued or in flight, for the save indicator. - saving = $state(false); - #anchorId: string | null = null; + // Ids checked for bulk actions (the open note's id on a plain click; grows + // via cmd-click / shift-click). Size > 1 swaps the editor for the bulk panel. + // The set and its anchor/cursor live in a composed model; the store keeps the + // open-note state and the editor-sync orchestration. + #selection = new SelectionModel(() => this.visibleIds); + get multiSelected(): ReadonlySet { + return this.#selection.ids; + } + + // Body persistence (debounce, retry, flush) lives in its own single-writer + // unit; the store composes one and delegates. A confirmed write updates the + // open note; a terminal failure surfaces an error. + #saveQueue = new SaveQueue({ + onPersisted: async (id, updated) => { + if (this.selected?.id === id) { + // Keep local body if the user kept typing past this save. + const localBody = this.selected.body; + this.selected = { ...updated, body: localBody }; + this.selectedTags = await tagsForNote(id); + } + this.error = null; + }, + onError: (e) => this.#fail(e), + }); + + #initialized = false; #refreshDebounced = debounce(() => void this.refresh(), 50); - #saveBody = debounce((id: string, body: string) => { - void this.#applyUpdate(id, { body }).finally(() => { - this.saving = false; - }); - }, 400); + // The revisit count rides the same 50ms window so a burst of change + // events (bulk delete, undo) costs one count query, not one per event. + #revisitCountDebounced = debounce(() => void this.#refreshRevisitCount(), 50); + #searchRefresh = debounce(() => void this.refresh(), SEARCH_DEBOUNCE_MS); + + /** Save status of the selected note, for the editor status bar. */ + get saveState(): SaveState { + return this.#saveQueue.stateFor(this.selected?.id); + } async init(): Promise { + if (this.#initialized) return; + this.#initialized = true; + // Listeners before the initial fetches: a change event arriving during + // startup must trigger a re-query, not be dropped. + await Promise.all([ + listen("notes:changed", () => { + this.#refreshDebounced(); + this.#revisitCountDebounced(); + }), + listen("tags:changed", () => void this.refreshTags()), + listen("workspaces:changed", () => void this.refreshWorkspaces()), + ]); await Promise.all([ this.refresh(), this.refreshTags(), this.refreshWorkspaces(), + this.#refreshRevisitCount(), ]); - await listen("notes:changed", () => this.#refreshDebounced()); - await listen("tags:changed", () => void this.refreshTags()); - await listen("workspaces:changed", () => void this.refreshWorkspaces()); } #filter(): NoteFilter { + if (this.revisitMode) return this.#revisitFilter(); const f: NoteFilter = {}; if (this.statusFilter === "archived") f.isArchived = true; if (this.statusFilter === "trash") f.isDeleted = true; - if (this.activeWorkspaceId) f.workspaceId = this.activeWorkspaceId; + if (this.activeWorkspaceId) { + f.workspaceId = this.activeWorkspaceId; + if (this.scopedTagId) f.tagIds = [this.scopedTagId]; + } if (this.activeTagId) f.tagIds = [this.activeTagId]; return f; } + // Oldest first: the longest-parked loop is the one to burn down first. + #revisitFilter(): NoteFilter { + return { + neverOpened: true, + createdBefore: new Date(Date.now() - REVISIT_AFTER_MS).toISOString(), + sortBy: "createdAt", + sortOrder: "asc", + }; + } + + // Monotonic refresh token: queries answer out of order (search per pause, + // list per filter click), so a response only lands while it is still the + // newest request; a slow earlier reply can never clobber a later one. + #refreshToken = 0; + async refresh(): Promise { + const token = ++this.#refreshToken; try { const text = this.searchText.trim(); if (text) { - this.searchResults = await searchNotes(text); + const results = await searchNotes(text); + if (token !== this.#refreshToken) return; + this.searchResults = results; } else { + const notes = await listNotes(this.#filter()); + if (token !== this.#refreshToken) return; this.searchResults = null; - this.notes = await listNotes(this.#filter()); + this.notes = notes; } this.error = null; + // In revisit mode the main list IS the revisit query, so the count + // stays in lockstep with the burn-down for free. + if (this.revisitMode && !this.searchResults) { + this.revisitCount = this.notes.length; + } + // Chips ride along on every refresh: notes:changed also fires when a + // note's inline tags change, which is exactly when they go stale. + if (this.activeWorkspaceId) { + void this.#refreshWorkspaceTags(); + } else if (this.workspaceTags.length > 0) { + this.workspaceTags = []; + } } catch (e) { + if (token !== this.#refreshToken) return; this.#fail(e); } } @@ -129,57 +226,141 @@ class LibraryStore { } setStatusFilter(filter: StatusFilter): void { + // Status (All / Archived / Trash) composes with the active space or tag, + // so it clears revisit and search but keeps the space/tag scope. this.statusFilter = filter; + this.revisitMode = false; this.searchText = ""; this.clearMultiSelect(); void this.refresh(); } - /** Show All Notes (null) or one workspace's collected notes. */ - selectWorkspace(workspaceId: string | null): void { - this.activeWorkspaceId = workspaceId; - this.statusFilter = "active"; + /** + * Clear every primary filter dimension so a caller can set exactly one. + * The space, tag, and revisit views are mutually exclusive; each entry + * point resets the rest, drops any scoped tag, and clears search and the + * multi-selection before choosing its own dimension. + */ + #resetForNavigation(): void { + this.activeWorkspaceId = null; this.activeTagId = null; + this.scopedTagId = null; + this.workspaceTags = []; + this.revisitMode = false; + this.statusFilter = "active"; this.searchText = ""; this.clearMultiSelect(); + } + + /** Show All Notes (null) or one workspace's collected notes. */ + selectWorkspace(workspaceId: string | null): void { + this.#resetForNavigation(); + this.activeWorkspaceId = workspaceId; + void this.refresh(); + } + + /** Show the open loops: capture-born notes never opened in the library. */ + selectRevisit(): void { + this.#resetForNavigation(); + this.revisitMode = true; void this.refresh(); } setTagFilter(tagId: string | null): void { + this.#resetForNavigation(); this.activeTagId = tagId; - this.activeWorkspaceId = null; - this.statusFilter = "active"; - this.searchText = ""; + void this.refresh(); + } + + /** + * Re-count the open loops (never-opened captures old enough to matter). + * Cheap and quiet: a failed count only affects a sidebar hint, and the + * next change event retries it. + */ + async #refreshRevisitCount(): Promise { + try { + const loops = await listNotes(this.#revisitFilter()); + this.revisitCount = loops.length; + } catch { + // Keep the stale count rather than surface an error for a hint. + } + } + + /** Toggle a chip: filter the active workspace's list by one of its tags. */ + toggleScopedTag(tagId: string): void { + if (!this.activeWorkspaceId) return; + this.scopedTagId = this.scopedTagId === tagId ? null : tagId; this.clearMultiSelect(); void this.refresh(); } + /** + * Re-query the chip row for the active workspace. The scoped tag is + * dropped when it no longer exists on the workspace's visible notes: a + * chip that vanished must not keep filtering the list. + */ + async #refreshWorkspaceTags(): Promise { + const id = this.activeWorkspaceId; + if (!id) { + this.workspaceTags = []; + return; + } + try { + const tags = await listWorkspaceTags(id); + if (this.activeWorkspaceId !== id) return; // switched away mid-flight + this.workspaceTags = tags; + if (this.scopedTagId && !tags.some((t) => t.id === this.scopedTagId)) { + this.scopedTagId = null; + void this.refresh(); + } + } catch (e) { + if (this.activeWorkspaceId !== id) return; + this.workspaceTags = []; + // The workspace can be deleted between the list refresh and this + // query; refreshWorkspaces resets the selection, nothing to surface. + if (!(e instanceof ApiError && e.code === "NOT_FOUND")) this.#fail(e); + } + } + setSearch(text: string): void { this.searchText = text; // Reset the multi-selection but keep the open note in the editor. - this.multiSelected = this.selected ? new Set([this.selected.id]) : new Set(); - this.#anchorId = this.selected?.id ?? null; - this.#lastRangeEnd = this.#anchorId; - void this.refresh(); + const openId = this.selected?.id ?? null; + this.#selection.reset(openId ? [openId] : [], openId); + if (text.trim()) { + this.#searchRefresh(); + } else { + // Clearing must feel instant: drop any pending keystroke debounce and + // go straight back to the list. + this.#searchRefresh.cancel(); + void this.refresh(); + } } async select(id: string): Promise { - this.multiSelected = new Set([id]); - this.#anchorId = id; - this.#lastRangeEnd = id; + this.#selection.reset([id], id); await this.#open(id); } async #open(id: string): Promise { // Flush any pending edit of the previous note before switching. - this.#saveBody.flush(); + this.#saveQueue.flushDebounce(); try { - this.selected = await getNote(id, true); + const note = await getNote(id, true); + // A queued edit (debounced or awaiting retry) is newer than what disk + // returned; showing the disk body would fork the note's history. + const queued = this.#saveQueue.peek(id); + this.selected = queued !== undefined ? { ...note, body: queued } : note; [this.selectedTags, this.selectedWorkspaces] = await Promise.all([ tagsForNote(id), workspacesForNote(id), ]); this.error = null; + // The touch in getNote released this note from the Revisit filter; + // get_note emits no change event, so sync the count (and, in revisit + // mode, the list burn-down) here. + void this.#refreshRevisitCount(); + if (this.revisitMode) void this.refresh(); } catch (e) { this.#fail(e); } @@ -203,20 +384,17 @@ class LibraryStore { } async toggleInSelection(id: string): Promise { - this.multiSelected = toggleSelection(this.multiSelected, id); - this.#anchorId = id; - this.#lastRangeEnd = id; + this.#selection.toggle(id); await this.#syncEditorToSelection(); } async extendSelectionTo(id: string): Promise { - this.multiSelected = rangeSelection(this.visibleIds, this.#anchorId, id); - this.#lastRangeEnd = id; + this.#selection.extendTo(id); await this.#syncEditorToSelection(); } async selectAllVisible(): Promise { - this.multiSelected = new Set(this.visibleIds); + this.#selection.selectAll(); await this.#syncEditorToSelection(); } @@ -225,9 +403,7 @@ class LibraryStore { * Returns the id the selection moved to so the view can reveal it. */ async moveSelection(delta: number, extend = false): Promise { - const current = - this.#lastRangeEnd ?? this.selected?.id ?? this.#anchorId; - const next = stepId(this.visibleIds, current, delta); + const next = this.#selection.step(delta, this.selected?.id ?? null); if (!next) return null; if (extend) { await this.extendSelectionTo(next); @@ -237,14 +413,8 @@ class LibraryStore { return next; } - // Active end of the selection: the last row clicked, toggled, or stepped to. - // Shift+arrow continues from here rather than from the anchor. - #lastRangeEnd: string | null = null; - clearMultiSelect(): void { - this.multiSelected = new Set(); - this.#anchorId = null; - this.#lastRangeEnd = null; + this.#selection.clear(); this.selected = null; this.selectedTags = []; this.selectedWorkspaces = []; @@ -254,11 +424,10 @@ class LibraryStore { async #syncEditorToSelection(): Promise { const ids = [...this.multiSelected]; if (ids.length === 1) { - this.#anchorId = ids[0]; - this.#lastRangeEnd = ids[0]; + this.#selection.setActive(ids[0]); if (this.selected?.id !== ids[0]) await this.#open(ids[0]); } else { - this.#saveBody.flush(); + this.#saveQueue.flushDebounce(); this.selected = null; this.selectedTags = []; this.selectedWorkspaces = []; @@ -268,48 +437,83 @@ class LibraryStore { // ---- bulk actions ---- async bulkSetPinned(isPinned: boolean): Promise { - await this.#bulk((id) => updateNote(id, { isPinned }).then(() => {})); + await this.#bulk((ids) => setNotesFlags(ids, { isPinned })); this.clearMultiSelect(); } async bulkSetArchived(isArchived: boolean): Promise { - await this.#bulk((id) => updateNote(id, { isArchived }).then(() => {})); + await this.#bulk((ids) => setNotesFlags(ids, { isArchived })); this.clearMultiSelect(); } async bulkDelete(): Promise { - this.#saveBody.cancel(); - await this.#bulk((id) => softDeleteNote(id).then(() => {})); + const ids = [...this.multiSelected]; + // Trash is reversible and Undo promises fidelity: persist any pending + // edit first, so a restored note holds the user's last keystrokes. + this.#saveQueue.cancelDebounce(); + await this.#saveQueue.flushIds(ids); + this.#saveQueue.drop(ids); + await this.#bulk((sel) => softDeleteNotes(sel)); this.clearMultiSelect(); + if (ids.length > 0) { + const label = ids.length === 1 ? "1 note" : `${ids.length} notes`; + toasts.show(`${label} moved to Trash`, { + label: "Undo", + run: () => void this.#undoSoftDelete(ids), + }); + } } async bulkRestore(): Promise { - await this.#bulk((id) => restoreNote(id).then(() => {})); + await this.#bulk((ids) => restoreNotes(ids)); this.clearMultiSelect(); } async bulkDestroy(): Promise { - await this.#bulk((id) => permanentlyDeleteNote(id, true)); + await this.destroyNotes([...this.multiSelected]); + } + + /** + * Permanently delete an explicit set of notes. Ids are an argument rather + * than a read of the live selection so confirm dialogs can snapshot them + * at ask time: the selection must not be able to drift between the dialog + * opening and the user confirming. + */ + async destroyNotes(ids: string[]): Promise { + if (ids.length === 0) return; + // Destroyed notes must also forget their queued edits, or the retry and + // every later flush re-attempts a write against a row that no longer + // exists and surfaces NOT_FOUND forever. + this.#saveQueue.cancelDebounce(); + this.#saveQueue.drop(ids); + try { + await destroyNotesCmd(ids, true); + this.error = null; + } catch (e) { + this.#fail(e); + } this.clearMultiSelect(); } async emptyTrash(): Promise { try { const trashed = await listNotes({ isDeleted: true }); - for (const note of trashed) { - await permanentlyDeleteNote(note.id, true); - } + this.#saveQueue.cancelDebounce(); + this.#saveQueue.drop(trashed.map((n) => n.id)); + await destroyNotesCmd( + trashed.map((n) => n.id), + true, + ); this.clearMultiSelect(); + if (trashed.length > 0) toasts.show("Trash emptied"); } catch (e) { this.#fail(e); } } - async #bulk(op: (id: string) => Promise): Promise { + async #bulk(op: (ids: string[]) => Promise): Promise { try { - for (const id of this.multiSelected) { - await op(id); - } + await op([...this.multiSelected]); this.error = null; } catch (e) { this.#fail(e); @@ -322,12 +526,18 @@ class LibraryStore { ? this.tags.find((t) => t.id === this.activeTagId) : null; - const note = await createNote(activeTag ? { tags: [activeTag.name] } : {}); + const note = await createNote( + activeTag ? { tags: [activeTag.name] } : {}, + ); // A note born inside a workspace joins it; the view stays put. if (this.activeWorkspaceId) { await addNoteToWorkspace(note.id, this.activeWorkspaceId); } this.statusFilter = "active"; + // A brand-new note can never match the Revisit filter (it is neither + // old nor forgotten), so leaving the mode on would hide the note the + // user just asked for. Exit it, exactly like trash and archived above. + this.revisitMode = false; if (!activeTag) this.activeTagId = null; this.searchText = ""; void this.refresh(); @@ -349,16 +559,86 @@ class LibraryStore { } } - /** Delete a workspace; its notes are kept. */ + /** + * Delete a workspace; its notes are kept. Immediate, with an Undo toast: + * the operation never destroys note data, so it earns the reversible-action + * treatment instead of a confirm dialog. + */ async removeWorkspace(id: string): Promise { + const ws = this.workspaces.find((w) => w.id === id); try { - await deleteWorkspace(id); + const memberIds = await deleteWorkspace(id); if (this.activeWorkspaceId === id) this.selectWorkspace(null); + // An open note's membership chips may have shown this workspace. + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + await this.refreshWorkspaces(); + if (ws) { + toasts.show(`Deleted "${ws.name}" - notes are kept`, { + label: "Undo", + run: () => void this.#undoWorkspaceDelete(ws.name, memberIds), + }); + } } catch (e) { this.#fail(e); } } + /** + * Undo for a workspace delete: recreate it by name and re-add every + * member. The ids come from the backend at delete time so archived and + * trashed members are restored too; a member destroyed in the meantime + * fails quietly into a plain toast rather than throwing back into the + * toast's action handler. + */ + async #undoWorkspaceDelete(name: string, memberIds: string[]): Promise { + try { + const ws = await getOrCreateWorkspace(name); + const results = await Promise.allSettled( + memberIds.map((noteId) => addNoteToWorkspace(noteId, ws.id)), + ); + await this.refreshWorkspaces(); + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + const failedCount = results.filter((r) => r.status === "rejected").length; + if (failedCount > 0) { + toasts.show( + `Restored "${name}" without ${failedCount} of ${memberIds.length} notes.`, + ); + } + } catch { + toasts.show(`Couldn't restore "${name}".`); + } + } + + /** + * Rename a workspace. Returns an inline-error shape rather than throwing + * so the row's edit state can show a duplicate-name rejection in place, + * mirroring the Sidebar's tag rename. + */ + async renameWorkspace( + id: string, + name: string, + ): Promise<{ ok: true } | { ok: false; message: string }> { + try { + await renameWorkspace(id, name); + await this.refreshWorkspaces(); + // An open note's membership chips may display the old name. + if (this.selected) { + this.selectedWorkspaces = await workspacesForNote(this.selected.id); + } + return { ok: true }; + } catch (e) { + const message = + e instanceof ApiError + ? friendlyMessage(e.code, e.message) + : friendlyMessage(""); + return { ok: false, message }; + } + } + /** Collect the open note into a workspace by name (created if missing). */ async addSelectedToWorkspace(name: string): Promise { if (!this.selected || !name.trim()) return; @@ -383,10 +663,10 @@ class LibraryStore { editBody(body: string): void { if (!this.selected) return; - // Optimistic local state; persistence is debounced. + // Optimistic local state; persistence is debounced. The note is dirty + // from this moment until a write of this (or a newer) body succeeds. this.selected.body = body; - this.saving = true; - this.#saveBody(this.selected.id, body); + this.#saveQueue.queue(this.selected.id, body); } editTitle(title: string): void { @@ -412,11 +692,19 @@ class LibraryStore { async deleteSelected(): Promise { if (!this.selected) return; - this.#saveBody.cancel(); - this.saving = false; + const id = this.selected.id; + // Trash is reversible and Undo promises fidelity: persist any pending + // edit first, so a restored note holds the user's last keystrokes. + this.#saveQueue.cancelDebounce(); + await this.#saveQueue.flushIds([id]); + this.#saveQueue.drop([id]); try { - await softDeleteNote(this.selected.id); + await softDeleteNote(id); this.clearMultiSelect(); + toasts.show("Moved to Trash", { + label: "Undo", + run: () => void this.#undoSoftDelete([id]), + }); } catch (e) { this.#fail(e); } @@ -433,12 +721,7 @@ class LibraryStore { async destroySelected(): Promise { if (!this.selected) return; - try { - await permanentlyDeleteNote(this.selected.id, true); - this.clearMultiSelect(); - } catch (e) { - this.#fail(e); - } + await this.destroyNotes([this.selected.id]); } async addTag(name: string): Promise { @@ -461,8 +744,32 @@ class LibraryStore { } } - flushPendingEdits(): void { - this.#saveBody.flush(); + /** + * Persist every queued edit now (note switch, window blur, export, quit). + * Resolves once the writes have settled; anything that still fails stays + * queued for the next flush. + */ + async flushPendingEdits(): Promise { + await this.#saveQueue.flushAll(); + } + + /** + * Undo for a soft delete: restore each id, then refresh. A note destroyed + * in the meantime (or otherwise gone) fails quietly into a plain toast + * instead of throwing back into the caller (the toast's action handler). + */ + async #undoSoftDelete(ids: string[]): Promise { + const results = await Promise.allSettled(ids.map((id) => restoreNote(id))); + await this.refresh(); + const failedCount = results.filter((r) => r.status === "rejected").length; + if (failedCount === 0) return; + const message = + failedCount === ids.length + ? ids.length === 1 + ? "Couldn't restore. It may already be gone." + : "Couldn't restore. The notes may already be gone." + : `Couldn't restore ${failedCount} of ${ids.length} notes.`; + toasts.show(message); } async #applyUpdate( diff --git a/src/lib/stores/library/save-queue.svelte.ts b/src/lib/stores/library/save-queue.svelte.ts new file mode 100644 index 0000000..5013344 --- /dev/null +++ b/src/lib/stores/library/save-queue.svelte.ts @@ -0,0 +1,156 @@ +// The library's body-save queue: debounced writes, one quiet retry, and +// flush-on-switch/blur/quit, kept as a single-writer unit apart from the rest +// of the store. It owns every "is this note persisted" decision; the store +// composes one instance and delegates. +// +// The only outward coupling is the open note: a confirmed write updates it, and +// a terminal failure surfaces an error. Both arrive via injected callbacks so +// this class never reaches back into the store's selection or filter state. + +import { debounce } from "$lib/debounce"; +import { + withMapEntry, + withoutMapKeys, + withSetEntry, + withoutSetEntries, +} from "$lib/reactive-collections"; +import { updateNote } from "$lib/api/client"; +import type { Note } from "$lib/api/types"; + +/** Selected-note save status for the editor status bar. */ +export type SaveState = "saved" | "saving" | "failed"; + +// One quiet retry this long after a failed body save; most failures (a +// competing writer briefly holding the database lock) clear well within it. +const SAVE_RETRY_MS = 2000; + +export interface SaveQueueDeps { + /** Apply a confirmed write. Called for every successful persist so the store + * can refresh the open note (and clear its error) when it is the one saved. */ + onPersisted: (id: string, updated: Note) => Promise | void; + /** Surface a terminal failure (after the single retry) to the store. */ + onError: (e: unknown) => void; +} + +export class SaveQueue { + // Bodies not yet confirmed persisted, by note id. An entry is only removed by + // a successful write, so a failed save stays queued for the next flush (note + // switch, blur, quit) instead of being silently dropped. Reassigned on change + // so the status bar tracks it reactively. + #unsaved = $state(new Map()); + // Note ids whose save failed even after the retry; drives "Not saved". + #failed = $state(new Set()); + // Scheduled retry per note id, so a newer write, a drop, or a flush can + // cancel it before it fires a stray write behind the caller's back. + #retryTimers = new Map>(); + + #debounced = debounce((id: string, body: string) => { + void this.#persist(id, body, true); + }, 400); + + #deps: SaveQueueDeps; + + constructor(deps: SaveQueueDeps) { + this.#deps = deps; + } + + /** The queued (freshest) body for an id, or undefined; lets the opener show + * it instead of the disk copy, which would fork the note's history. */ + peek(id: string): string | undefined { + return this.#unsaved.get(id); + } + + /** Save status of one note id, for the editor status bar. */ + stateFor(id: string | undefined): SaveState { + if (!id || !this.#unsaved.has(id)) return "saved"; + return this.#failed.has(id) ? "failed" : "saving"; + } + + /** Queue an optimistic edit; the write is debounced (400ms). */ + queue(id: string, body: string): void { + this.#unsaved = withMapEntry(this.#unsaved, id, body); + this.#debounced(id, body); + } + + /** Run the pending debounced write now, e.g. before switching notes. */ + flushDebounce(): void { + this.#debounced.flush(); + } + + /** + * Persist every queued edit now, no retry (note switch, window blur, export, + * quit). Cancels the debounce rather than flushing it: the retry-enabled path + * could otherwise fire a stray write after this resolves. #unsaved already + * holds the latest body for every note, so one no-retry write per note covers + * the just-typed edit too. + */ + async flushAll(): Promise { + this.#debounced.cancel(); + await Promise.all( + [...this.#unsaved.entries()].map(([id, body]) => + this.#persist(id, body, false), + ), + ); + } + + /** Persist queued edits for specific ids now, no retry (before a soft delete, + * so a restored note keeps its last keystrokes). */ + async flushIds(ids: string[]): Promise { + await Promise.all( + ids + .filter((id) => this.#unsaved.has(id)) + .map((id) => this.#persist(id, this.#unsaved.get(id) as string, false)), + ); + } + + /** Cancel the debounce ahead of a delete path that will drop the ids. */ + cancelDebounce(): void { + this.#debounced.cancel(); + } + + /** Forget queued edits for notes being discarded, so no later flush or retry + * writes against a row that no longer exists. */ + drop(ids: string[]): void { + this.#unsaved = withoutMapKeys(this.#unsaved, ids); + this.#failed = withoutSetEntries(this.#failed, ids); + for (const id of ids) this.#clearRetryTimer(id); + } + + async #persist(id: string, body: string, canRetry: boolean): Promise { + // Any write attempt for this id, whether from the debounce, a retry, or a + // flush, supersedes an outstanding scheduled retry for the same id. + this.#clearRetryTimer(id); + try { + const updated = await updateNote(id, { body }); + // Confirmed on disk. Clear the queue entry unless a newer edit superseded + // the body this write carried. + if (this.#unsaved.get(id) === body) { + this.#unsaved = withoutMapKeys(this.#unsaved, [id]); + } + if (this.#failed.has(id)) { + this.#failed = withoutSetEntries(this.#failed, [id]); + } + await this.#deps.onPersisted(id, updated); + } catch (e) { + if (canRetry) { + const timer = setTimeout(() => { + this.#retryTimers.delete(id); + const latest = this.#unsaved.get(id); + if (latest !== undefined) void this.#persist(id, latest, false); + }, SAVE_RETRY_MS); + this.#retryTimers.set(id, timer); + } else { + this.#failed = withSetEntry(this.#failed, id); + this.#deps.onError(e); + } + } + } + + #clearRetryTimer(id: string): void { + const timer = this.#retryTimers.get(id); + if (timer !== undefined) { + clearTimeout(timer); + this.#retryTimers.delete(id); + } + } +} diff --git a/src/lib/stores/library/selection.svelte.ts b/src/lib/stores/library/selection.svelte.ts new file mode 100644 index 0000000..14faa03 --- /dev/null +++ b/src/lib/stores/library/selection.svelte.ts @@ -0,0 +1,71 @@ +// The list multi-selection: which note ids are checked, plus the anchor and +// active end that shift-range and arrow-step read from. The open note itself +// (selected / selectedTags / selectedWorkspaces) stays in the store; this owns +// only the checkbox set and its navigation cursor. +// +// The current visible id list is injected (it depends on the store's filter / +// search results), so this class never reaches into query state. + +import { rangeSelection, stepId, toggleSelection } from "$lib/selection"; + +export class SelectionModel { + ids = $state>(new Set()); + + // The row a range grows from, and the last row acted on. Shift+arrow + // continues from the active end rather than from the anchor. + #anchor: string | null = null; + #activeEnd: string | null = null; + + #visibleIds: () => string[]; + + constructor(visibleIds: () => string[]) { + this.#visibleIds = visibleIds; + } + + has(id: string): boolean { + return this.ids.has(id); + } + + get anchor(): string | null { + return this.#anchor; + } + + /** Replace the selection with exactly these ids, anchored at `anchor`. */ + reset(ids: string[], anchor: string | null): void { + this.ids = new Set(ids); + this.#anchor = anchor; + this.#activeEnd = anchor; + } + + clear(): void { + this.reset([], null); + } + + toggle(id: string): void { + this.ids = toggleSelection(this.ids, id); + this.#anchor = id; + this.#activeEnd = id; + } + + extendTo(id: string): void { + this.ids = rangeSelection(this.#visibleIds(), this.#anchor, id); + this.#activeEnd = id; + } + + selectAll(): void { + this.ids = new Set(this.#visibleIds()); + } + + /** Next id when stepping by `delta` from the active end (falling back to the + * open note, then the anchor); null at the edge of the visible list. */ + step(delta: number, fallback: string | null): string | null { + const current = this.#activeEnd ?? fallback ?? this.#anchor; + return stepId(this.#visibleIds(), current, delta); + } + + /** Pin both cursor ends to one id (a single row became the selection). */ + setActive(id: string): void { + this.#anchor = id; + this.#activeEnd = id; + } +} diff --git a/src/lib/stores/links.svelte.ts b/src/lib/stores/links.svelte.ts new file mode 100644 index 0000000..02c313d --- /dev/null +++ b/src/lib/stores/links.svelte.ts @@ -0,0 +1,77 @@ +// Link preferences (Svelte 5 runes): how links look and open inside notes. +// Persisted to the settings KV like editorPrefs. The editor consumes these +// through a CodeMirror state field (see src/lib/editor/links.ts), synced by +// Editor.svelte whenever a value changes, so edits apply live. + +import { getSetting, setSetting } from "$lib/api/client"; +import { + DEFAULT_LINK_PREFS, + type LinkOpenWith, + type LinkUnderline, + type LinkPrefsSnapshot, +} from "$lib/editor"; + +const KEY_OPEN = "links.openWith"; +const KEY_UNDERLINE = "links.underline"; +const KEY_TOOLTIP = "links.tooltip"; +const KEY_EXTERNAL = "links.externalIndicator"; + +class LinkPrefsStore { + openWith = $state(DEFAULT_LINK_PREFS.openWith); + underline = $state(DEFAULT_LINK_PREFS.underline); + tooltip = $state(DEFAULT_LINK_PREFS.tooltip); + externalIndicator = $state(DEFAULT_LINK_PREFS.externalIndicator); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const [open, ul, tip, ext] = await Promise.all([ + getSetting(KEY_OPEN), + getSetting(KEY_UNDERLINE), + getSetting(KEY_TOOLTIP), + getSetting(KEY_EXTERNAL), + ]); + if (open === "click" || open === "modclick") this.openWith = open; + if (ul === "always" || ul === "hover" || ul === "never") this.underline = ul; + if (typeof tip === "boolean") this.tooltip = tip; + if (typeof ext === "boolean") this.externalIndicator = ext; + } catch { + // Settings are best-effort; fall back to defaults silently. + } + } + + /** Plain object for dispatching into CodeMirror (no reactive proxies). */ + snapshot(): LinkPrefsSnapshot { + return { + openWith: this.openWith, + underline: this.underline, + tooltip: this.tooltip, + externalIndicator: this.externalIndicator, + }; + } + + setOpenWith(v: LinkOpenWith): void { + this.openWith = v; + void setSetting(KEY_OPEN, v); + } + + setUnderline(v: LinkUnderline): void { + this.underline = v; + void setSetting(KEY_UNDERLINE, v); + } + + setTooltip(v: boolean): void { + this.tooltip = v; + void setSetting(KEY_TOOLTIP, v); + } + + setExternalIndicator(v: boolean): void { + this.externalIndicator = v; + void setSetting(KEY_EXTERNAL, v); + } +} + +export const linkPrefs = new LinkPrefsStore(); diff --git a/src/lib/stores/sidebar.svelte.ts b/src/lib/stores/sidebar.svelte.ts new file mode 100644 index 0000000..808896a --- /dev/null +++ b/src/lib/stores/sidebar.svelte.ts @@ -0,0 +1,60 @@ +// Sidebar layout state (Svelte 5 runes): drag-resizable width and an +// open/closed toggle. Persisted to the settings KV like editorPrefs, so the +// layout comes back the way it was left. + +import { getSetting, setSetting } from "$lib/api/client"; + +const KEY_WIDTH = "sidebar.width"; +const KEY_COLLAPSED = "sidebar.collapsed"; + +export const SIDEBAR_MIN = 150; +export const SIDEBAR_MAX = 420; +export const SIDEBAR_DEFAULT = 190; + +class SidebarState { + width = $state(SIDEBAR_DEFAULT); + collapsed = $state(false); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const [w, c] = await Promise.all([ + getSetting(KEY_WIDTH), + getSetting(KEY_COLLAPSED), + ]); + if (typeof w === "number" && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) this.width = w; + if (typeof c === "boolean") this.collapsed = c; + } catch { + // Settings are best-effort; fall back to defaults silently. + } + } + + #clamp(w: number): number { + return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(w))); + } + + /** Live-updates during a drag; call commitWidth() once when it ends. */ + setWidth(w: number): void { + this.width = this.#clamp(w); + } + + /** Persist the width once per gesture instead of on every pointermove. */ + commitWidth(): void { + void setSetting(KEY_WIDTH, this.width); + } + + resetWidth(): void { + this.width = SIDEBAR_DEFAULT; + this.commitWidth(); + } + + toggle(): void { + this.collapsed = !this.collapsed; + void setSetting(KEY_COLLAPSED, this.collapsed); + } +} + +export const sidebar = new SidebarState(); diff --git a/src/lib/stores/toasts.svelte.test.ts b/src/lib/stores/toasts.svelte.test.ts new file mode 100644 index 0000000..3ec1f68 --- /dev/null +++ b/src/lib/stores/toasts.svelte.test.ts @@ -0,0 +1,110 @@ +// Queue, timer, and eviction tests for the toast store. No component is +// mounted here: the store's timer/queue logic is pure enough to drive with +// fake timers directly, per the toast host's own design note. +// +// Fresh module per test via vi.resetModules() + dynamic import (mirrors +// library.svelte.test.ts), so the module-level singleton starts clean and +// state never bleeds between tests. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +async function load() { + const mod = await import("$lib/stores/toasts.svelte"); + return mod.toasts; +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("toasts queue", () => { + it("show() queues a toast that auto-dismisses after ~5s", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + expect(toasts.items.map((t) => t.id)).toEqual([id]); + + await vi.advanceTimersByTimeAsync(4999); + expect(toasts.items).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(toasts.items).toHaveLength(0); + }); + + it("dismiss() removes a toast immediately and cancels its timer", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + toasts.dismiss(id); + expect(toasts.items).toHaveLength(0); + + // If the timer were not cancelled it would try to dismiss an already-gone + // toast; nothing should throw and the list stays empty either way. + await vi.advanceTimersByTimeAsync(5000); + expect(toasts.items).toHaveLength(0); + }); + + it("pausing while hovered holds the timer; resuming continues from where it left off", async () => { + const toasts = await load(); + const id = toasts.show("Moved to Trash"); + + await vi.advanceTimersByTimeAsync(4000); + toasts.pause(id); + // Paused: well past the original 5s deadline, the toast must still be there. + await vi.advanceTimersByTimeAsync(5000); + expect(toasts.items).toHaveLength(1); + + toasts.resume(id); + // ~1s of the original countdown remained when it was paused. + await vi.advanceTimersByTimeAsync(999); + expect(toasts.items).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(toasts.items).toHaveLength(0); + }); + + it("caps the visible stack at 3 and evicts the oldest (FIFO) past the cap", async () => { + const toasts = await load(); + const a = toasts.show("first"); + const b = toasts.show("second"); + const c = toasts.show("third"); + expect(toasts.items.map((t) => t.id)).toEqual([a, b, c]); + + const d = toasts.show("fourth"); + expect(toasts.items.map((t) => t.id)).toEqual([b, c, d]); + expect(toasts.items).toHaveLength(3); + }); + + it("an evicted toast's timer cannot fire a stray dismiss later", async () => { + const toasts = await load(); + toasts.show("first"); + const b = toasts.show("second"); + const c = toasts.show("third"); + const d = toasts.show("fourth"); + + // Just short of the surviving toasts' own 5s auto-dismiss: if the + // evicted toast's timer were left running it would be a harmless no-op + // at worst, but the stack must still hold exactly [b, c, d] until then. + await vi.advanceTimersByTimeAsync(4999); + expect(toasts.items.map((t) => t.id)).toEqual([b, c, d]); + }); + + it("activate() runs the toast's action and dismisses it", async () => { + const toasts = await load(); + const run = vi.fn(); + const id = toasts.show("Moved to Trash", { label: "Undo", run }); + + toasts.activate(id); + + expect(run).toHaveBeenCalledTimes(1); + expect(toasts.items).toHaveLength(0); + }); + + it("activate() on a toast without an action never throws", async () => { + const toasts = await load(); + const id = toasts.show("Trash emptied"); + expect(() => toasts.activate(id)).not.toThrow(); + expect(toasts.items).toHaveLength(0); + }); +}); diff --git a/src/lib/stores/toasts.svelte.ts b/src/lib/stores/toasts.svelte.ts new file mode 100644 index 0000000..b997d5a --- /dev/null +++ b/src/lib/stores/toasts.svelte.ts @@ -0,0 +1,96 @@ +// Undo-toast queue: reversible actions (soft delete) execute immediately and +// offer a short window to undo, instead of blocking on a confirm dialog. +// Timer/queue logic lives entirely in this class so toasts.svelte.test.ts can +// drive it with fake timers without mounting the host component. + +const AUTO_DISMISS_MS = 5000; +// How many toasts stack at once; a new one past the cap evicts the oldest +// (FIFO) rather than growing the stack or queueing silently. +const MAX_VISIBLE = 3; + +export interface ToastAction { + label: string; + run: () => void; +} + +export interface ToastItem { + readonly id: string; + readonly message: string; + readonly action?: ToastAction; +} + +class ToastStore { + items = $state([]); + + #nextId = 0; + #timers = new Map>(); + // Time left when a toast is paused (hovered), so resuming continues the + // countdown instead of restarting it. + #remaining = new Map(); + #startedAt = new Map(); + + /** Queue a toast; returns its id (dismiss/pause/resume/activate take it). */ + show(message: string, action?: ToastAction): string { + const id = `toast-${++this.#nextId}`; + const next = [...this.items, { id, message, action }]; + if (next.length > MAX_VISIBLE) { + const evicted = next.shift(); + if (evicted) this.#clearTimer(evicted.id); + } + this.items = next; + this.#schedule(id, AUTO_DISMISS_MS); + return id; + } + + /** Run a toast's action, then dismiss it. */ + activate(id: string): void { + const toast = this.items.find((t) => t.id === id); + this.dismiss(id); + toast?.action?.run(); + } + + dismiss(id: string): void { + this.#clearTimer(id); + this.items = this.items.filter((t) => t.id !== id); + } + + /** Hold the auto-dismiss while the toast is hovered. */ + pause(id: string): void { + const timer = this.#timers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + this.#timers.delete(id); + const started = this.#startedAt.get(id) ?? Date.now(); + const remaining = this.#remaining.get(id) ?? AUTO_DISMISS_MS; + this.#remaining.set(id, Math.max(0, remaining - (Date.now() - started))); + } + + /** Resume the auto-dismiss countdown from where it was paused. */ + resume(id: string): void { + if (!this.items.some((t) => t.id === id)) return; + this.#schedule(id, this.#remaining.get(id) ?? AUTO_DISMISS_MS); + } + + #schedule(id: string, ms: number): void { + // Replace, never stack: a leftover timer for the same id would fire the + // old, earlier dismissal and cut the new countdown short. + const existing = this.#timers.get(id); + if (existing !== undefined) clearTimeout(existing); + this.#startedAt.set(id, Date.now()); + this.#remaining.set(id, ms); + this.#timers.set( + id, + setTimeout(() => this.dismiss(id), ms), + ); + } + + #clearTimer(id: string): void { + const timer = this.#timers.get(id); + if (timer !== undefined) clearTimeout(timer); + this.#timers.delete(id); + this.#remaining.delete(id); + this.#startedAt.delete(id); + } +} + +export const toasts = new ToastStore(); diff --git a/src/lib/stores/updater.svelte.ts b/src/lib/stores/updater.svelte.ts index 0abcb53..01ef4f1 100644 --- a/src/lib/stores/updater.svelte.ts +++ b/src/lib/stores/updater.svelte.ts @@ -8,6 +8,7 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { getSetting, setSetting, deleteSetting } from "$lib/api/client"; +import { library } from "$lib/stores/library.svelte"; import { snoozeDeadline, type SnoozeKind } from "$lib/updater-snooze"; export type { SnoozeKind } from "$lib/updater-snooze"; @@ -126,6 +127,11 @@ class UpdaterStore { async restart() { if (this.status !== "ready") return; + // Flush here, before relaunch: the Rust quit interceptor deliberately + // lets the restart's exit request pass untouched (holding it would + // strand the freshly installed update), so this is the only place the + // pending edits can be saved on the update path. + await library.flushPendingEdits(); await relaunch(); } diff --git a/src/lib/tag-name.test.ts b/src/lib/tag-name.test.ts new file mode 100644 index 0000000..934f176 --- /dev/null +++ b/src/lib/tag-name.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { normalizeTagInput } from "./tag-name"; + +describe("normalizeTagInput", () => { + it("trims surrounding whitespace", () => { + expect(normalizeTagInput(" project ")).toBe("project"); + }); + + it("strips a single leading hash", () => { + expect(normalizeTagInput("#idea")).toBe("idea"); + }); + + it("strips a leading hash followed by whitespace", () => { + expect(normalizeTagInput("# idea")).toBe("idea"); + }); + + it("rejects an empty result", () => { + expect(normalizeTagInput("")).toBeNull(); + expect(normalizeTagInput(" ")).toBeNull(); + expect(normalizeTagInput("#")).toBeNull(); + expect(normalizeTagInput("# ")).toBeNull(); + }); + + it("leaves an inner hash untouched", () => { + expect(normalizeTagInput("c#lang")).toBe("c#lang"); + }); + + it("only strips one leading hash; the server strips the rest", () => { + expect(normalizeTagInput("##idea")).toBe("#idea"); + }); +}); diff --git a/src/lib/tag-name.ts b/src/lib/tag-name.ts new file mode 100644 index 0000000..e683b26 --- /dev/null +++ b/src/lib/tag-name.ts @@ -0,0 +1,12 @@ +// Client-side pre-check for a tag rename input, before it is sent to the +// backend. `store::update_tag` (via `domain::normalize_tag_name`) is the +// source of truth for the canonical form: lowercase, collapsed whitespace, +// all leading '#' stripped. This only trims, strips one leading '#', and +// rejects an empty result, so the UI can refuse an obviously-empty commit +// without a round trip; the server-normalized name comes back on refresh. +export function normalizeTagInput(raw: string): string | null { + const trimmed = raw.trim(); + const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + const result = withoutHash.trim(); + return result.length > 0 ? result : null; +} diff --git a/src/lib/wysiwyg.ts b/src/lib/wysiwyg.ts deleted file mode 100644 index c05bf37..0000000 --- a/src/lib/wysiwyg.ts +++ /dev/null @@ -1,278 +0,0 @@ -// WYSIWYG preview extension for CodeMirror 6. -// -// When previewModeField is true (Aa toolbar closed), markdown syntax markers -// are hidden via replace decorations and block elements get visual treatment. -// The document stays fully editable - the raw markdown is unchanged, just -// rendered differently. - -import { - EditorView, - Decoration, - type DecorationSet, - ViewPlugin, - type ViewUpdate, - WidgetType, - keymap, -} from "@codemirror/view"; -import { StateEffect, StateField, RangeSetBuilder } from "@codemirror/state"; -import { syntaxTree } from "@codemirror/language"; - -// --------------------------------------------------------------------------- -// Mode state -// --------------------------------------------------------------------------- - -export const setPreviewMode = StateEffect.define(); - -export const previewModeField = StateField.define({ - create: () => false, - update(val, tr) { - for (const e of tr.effects) { - if (e.is(setPreviewMode)) return e.value; - } - return val; - }, -}); - -// --------------------------------------------------------------------------- -// Widgets -// --------------------------------------------------------------------------- - -class BulletWidget extends WidgetType { - toDOM() { - const s = document.createElement("span"); - s.className = "cm-wysiwyg-bullet"; - s.textContent = "•"; - return s; - } - ignoreEvent() { - return true; - } -} - -class NumberWidget extends WidgetType { - constructor(readonly num: number) { - super(); - } - eq(o: NumberWidget) { - return o.num === this.num; - } - toDOM() { - const s = document.createElement("span"); - s.className = "cm-wysiwyg-number"; - s.textContent = `${this.num}.`; - return s; - } - ignoreEvent() { - return true; - } -} - -// --------------------------------------------------------------------------- -// Decoration builder -// --------------------------------------------------------------------------- - -type Deco = { from: number; to: number; deco: Decoration }; - -function buildDecorations(view: EditorView): DecorationSet { - const state = view.state; - const collected: Deco[] = []; - // Track lines that already have a line-deco so we don't add it twice. - const lineDecoAdded = new Set(); - - for (const { from, to } of view.visibleRanges) { - syntaxTree(state).iterate({ - from, - to, - enter(node) { - const { name, from: nFrom, to: nTo } = node; - - // --- Inline: hide emphasis/strikethrough/code marks --- - if ( - name === "EmphasisMark" || - name === "StrikethroughMark" || - name === "CodeMark" - ) { - collected.push({ from: nFrom, to: nTo, deco: Decoration.replace({}) }); - return false; - } - - // --- Inline: hide link markup, keep link text visible --- - if (name === "Link") { - const raw = state.doc.sliceString(nFrom, nTo); - // Find ]( which separates link text from URL - const splitIdx = raw.indexOf("]("); - if (splitIdx !== -1) { - // Hide opening [ - collected.push({ - from: nFrom, - to: nFrom + 1, - deco: Decoration.replace({}), - }); - // Hide ](...) to end of link - collected.push({ - from: nFrom + splitIdx, - to: nTo, - deco: Decoration.replace({}), - }); - } - return false; - } - - // --- Block: list markers (- / * / + / 1.) --- - if (name === "ListMark") { - const line = state.doc.lineAt(nFrom); - const m = line.text.match(/^(\s*)([-*+]|\d+\.)\s+/); - if (m) { - // markerEnd covers indent + marker char(s) + trailing space - const markerEnd = line.from + m[0].length; - const isOrdered = /^\d+\./.test(m[2]); - if (isOrdered) { - const num = parseInt(m[2], 10); - collected.push({ - from: line.from + m[1].length, // after indent - to: markerEnd, - deco: Decoration.replace({ widget: new NumberWidget(num) }), - }); - } else { - collected.push({ - from: line.from + m[1].length, - to: markerEnd, - deco: Decoration.replace({ widget: new BulletWidget() }), - }); - } - } - return false; - } - - // --- Block: blockquote marker (>) --- - if (name === "QuoteMark") { - const line = state.doc.lineAt(nFrom); - const m = line.text.match(/^(>\s*)/); - if (m) { - const markerEnd = line.from + m[0].length; - // Line decoration for styling - add once per line - if (!lineDecoAdded.has(line.from)) { - lineDecoAdded.add(line.from); - collected.push({ - from: line.from, - to: line.from, - deco: Decoration.line({ class: "cm-wysiwyg-blockquote" }), - }); - } - // Replace the > prefix - collected.push({ - from: line.from, - to: markerEnd, - deco: Decoration.replace({}), - }); - } - return false; - } - }, - }); - } - - // Sort by from asc, then to asc (required by RangeSetBuilder). - collected.sort((a, b) => a.from - b.from || a.to - b.to); - - const builder = new RangeSetBuilder(); - for (const { from, to, deco } of collected) { - builder.add(from, to, deco); - } - return builder.finish(); -} - -// --------------------------------------------------------------------------- -// ViewPlugin -// --------------------------------------------------------------------------- - -const wysiwygPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = view.state.field(previewModeField) - ? buildDecorations(view) - : Decoration.none; - } - - update(u: ViewUpdate) { - const was = u.startState.field(previewModeField); - const is = u.state.field(previewModeField); - if (is) { - if (u.docChanged || u.viewportChanged || was !== is) { - this.decorations = buildDecorations(u.view); - } - } else if (was !== is) { - this.decorations = Decoration.none; - } - } - }, - { decorations: (v) => v.decorations }, -); - -// --------------------------------------------------------------------------- -// Smart Backspace for block elements -// -// Exported for unit testing independent of CM6. -// --------------------------------------------------------------------------- - -/** - * Given a line's text and its document-start offset, returns the range - * occupied by the block marker (including trailing whitespace), or null if - * the line does not start with one. - */ -export function blockMarkerRange( - lineText: string, - lineFrom: number, -): { from: number; to: number } | null { - const m = lineText.match(/^(\s*)(>\s*|[-*+]\s+|\d+\.\s+)/); - if (!m) return null; - return { from: lineFrom + m[1].length, to: lineFrom + m[0].length }; -} - -const wysiwygKeymap = keymap.of([ - { - key: "Backspace", - run(view) { - if (!view.state.field(previewModeField)) return false; - const sel = view.state.selection.main; - if (!sel.empty) return false; - - const line = view.state.doc.lineAt(sel.from); - const range = blockMarkerRange(line.text, line.from); - if (!range || sel.from !== range.to) return false; - - view.dispatch({ - changes: { from: range.from, to: range.to, insert: "" }, - }); - return true; - }, - }, -]); - -// --------------------------------------------------------------------------- -// Public extension bundle -// --------------------------------------------------------------------------- - -export function wysiwygExtension() { - return [previewModeField, wysiwygPlugin, wysiwygKeymap]; -} - -// --------------------------------------------------------------------------- -// Theme -// --------------------------------------------------------------------------- - -export const wysiwygTheme = EditorView.baseTheme({ - ".cm-wysiwyg-bullet, .cm-wysiwyg-number": { - color: "var(--text)", - marginRight: "0.35em", - userSelect: "none", - }, - ".cm-wysiwyg-blockquote": { - borderLeft: "3px solid var(--accent)", - paddingLeft: "12px", - color: "var(--text-secondary)", - fontStyle: "italic", - }, -}); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 14c87ee..673ff6f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,7 +6,7 @@ import { getVersion } from "@tauri-apps/api/app"; import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; - import { exportNoteFile } from "$lib/api/client"; + import { exportNoteFile, quitApp } from "$lib/api/client"; import Sidebar from "$lib/components/Sidebar.svelte"; import NoteList from "$lib/components/NoteList.svelte"; import NoteEditor from "$lib/components/NoteEditor.svelte"; @@ -15,10 +15,15 @@ import CommandPalette from "$lib/components/CommandPalette.svelte"; import UpdatePanel from "$lib/components/UpdatePanel.svelte"; import SettingsView from "$lib/components/SettingsView.svelte"; + import ConfirmDialog from "$lib/components/ConfirmDialog.svelte"; + import Toast from "$lib/components/Toast.svelte"; import { library } from "$lib/stores/library.svelte"; import { updater } from "$lib/stores/updater.svelte"; import { editorPrefs } from "$lib/stores/editor.svelte"; + import { sidebar } from "$lib/stores/sidebar.svelte"; + import { linkPrefs as linkPrefsStore } from "$lib/stores/links.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; + import { confirmDialog } from "$lib/stores/confirm.svelte"; let appVersion = $state(""); let paletteOpen = $state(false); @@ -28,6 +33,8 @@ onMount(() => { void library.init(); void editorPrefs.init(); + void sidebar.init(); + void linkPrefsStore.init(); void contexting.init(); void getVersion().then((v) => (appVersion = v)); updater.start(); @@ -55,7 +62,15 @@ void exportSelectedNote(); }).then((un) => (unlistenExport = un)); - const flush = () => library.flushPendingEdits(); + // Quit handshake: persist the debounced edit, then tell Rust to exit for + // real. If this webview is hung the Rust-side fallback exits anyway. + let unlistenQuit: (() => void) | undefined; + void listen("app:quit-requested", async () => { + await library.flushPendingEdits(); + await quitApp(); + }).then((un) => (unlistenQuit = un)); + + const flush = () => void library.flushPendingEdits(); window.addEventListener("blur", flush); window.addEventListener("keydown", onKeydown); return () => { @@ -64,6 +79,7 @@ unlistenSettings?.(); unlistenNewNote?.(); unlistenExport?.(); + unlistenQuit?.(); window.removeEventListener("blur", flush); window.removeEventListener("keydown", onKeydown); }; @@ -77,6 +93,10 @@ } function onKeydown(e: KeyboardEvent) { + // The confirm dialog stops propagation itself, but that only covers keys + // dispatched through it; this guard catches the rest (focus on body after + // an invoker unmounted) so nothing moves under an open modal. + if (confirmDialog.request) return; const mod = e.metaKey || e.ctrlKey; // ⌘K toggles the command palette from anywhere, including input fields. if (mod && e.key === "k") { @@ -99,6 +119,12 @@ editorPrefs.resetZoom(); return; } + // ⌘\ toggles the sidebar from anywhere, including input fields. + if (mod && e.key === "\\") { + e.preventDefault(); + sidebar.toggle(); + return; + } if (isTypingTarget(e.target)) { // Escape in the search field clears the search; everything else is typing. if ( @@ -168,20 +194,85 @@ await exportNoteFile(path, note.body); } - async function confirmBulkDestroy() { - const n = library.multiSelected.size; - const what = n === 1 ? "this note" : `these ${n} notes`; - if (window.confirm(`Permanently delete ${what}? This cannot be undone.`)) { - await library.bulkDestroy(); + // Sidebar resize: pointer capture keeps the gesture on the handle even when + // the pointer outruns it; width persists once at release, not per move. + let draggingSidebar = $state(false); + + function startSidebarDrag(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + const handle = e.currentTarget as HTMLElement; + handle.setPointerCapture(e.pointerId); + draggingSidebar = true; + const startX = e.clientX; + const startWidth = sidebar.width; + const move = (ev: PointerEvent) => sidebar.setWidth(startWidth + ev.clientX - startX); + const up = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + draggingSidebar = false; + sidebar.commitWidth(); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + } + + function onHandleKeydown(e: KeyboardEvent) { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + sidebar.setWidth(sidebar.width + (e.key === "ArrowLeft" ? -10 : 10)); + sidebar.commitWidth(); } } + + async function confirmBulkDestroy() { + // Snapshot the ids when the dialog opens: the selection could otherwise + // drift while it is up (menu events, cross-window refreshes) and the + // confirm would destroy whatever is selected at resolve time instead. + const ids = [...library.multiSelected]; + if (ids.length === 0) return; + const what = ids.length === 1 ? "this note" : `these ${ids.length} notes`; + const ok = await confirmDialog.ask({ + title: `Delete ${what} permanently?`, + body: "This action cannot be undone.", + confirmLabel: "Delete Forever", + tone: "danger", + }); + if (ok) await library.destroyNotes(ids); + } {#if settingsOpen} (settingsOpen = false)} /> {:else} -
- +
+ {#if !sidebar.collapsed} + + + + + + {/if}
{#if library.multiSelected.size > 1} @@ -197,16 +288,37 @@ + +