Compare commits

...

27 Commits

Author SHA1 Message Date
lucaronin
c4c6bb6c57 fix: restore folder row toggle 2026-04-23 09:27:17 +02:00
lucaronin
35dd2dd47b docs: add/update ADR for note retargeting (guard — from commit 2c9361d7) 2026-04-23 08:07:56 +02:00
lucaronin
89141bae14 chore: ratchet codescene thresholds 2026-04-23 00:53:45 +02:00
lucaronin
2b9294f884 style: format note move backend 2026-04-23 00:41:04 +02:00
lucaronin
a756af54ec fix: keep note drop targets active during drag 2026-04-23 00:27:09 +02:00
lucaronin
b01b156dfb fix: align folder rows with sidebar items 2026-04-23 00:21:20 +02:00
lucaronin
2c9361d704 feat: retarget notes to types and folders 2026-04-22 23:56:00 +02:00
lucaronin
cea71a4fd9 Merge branch 'main' of https://github.com/refactoringhq/tolaria 2026-04-22 22:54:40 +02:00
lucaronin
8b73ef5836 feat: add sidebar folder rename and delete actions 2026-04-22 22:49:16 +02:00
lucaronin
a6a727a4c0 fix: align inspector create-type typing 2026-04-22 22:42:04 +02:00
lucaronin
bf13eed3ab fix: handle filename collisions in create flows 2026-04-22 22:37:01 +02:00
lucaronin
a27a70e552 fix: allow PostHog and Google Fonts in app CSP 2026-04-22 22:24:45 +02:00
lucaronin
ca92bf4f77 fix: restore keyboard shortcut typing 2026-04-22 21:22:35 +02:00
lucaronin
9fda0a67ec fix: make note renames crash-safe 2026-04-22 21:18:38 +02:00
lucaronin
0ffb7c65a9 Ignore history shortcuts while editing 2026-04-22 21:06:27 +02:00
lucaronin
633d9f1496 Add security policy and private reporting guidance 2026-04-22 20:46:24 +02:00
lucaronin
ba3d413b94 fix: recover from missing note paths 2026-04-22 20:18:26 +02:00
lucaronin
f3286922ad fix: satisfy clippy for MCP status 2026-04-22 19:28:58 +02:00
lucaronin
b0b476c9e5 fix: harden external AI tool setup 2026-04-22 19:18:52 +02:00
lucaronin
dac696b752 fix: guard toolbar popover anchors 2026-04-22 17:57:30 +02:00
lucaronin
cda87ee6c0 feat: add sidebar folder rename and delete actions 2026-04-22 17:16:56 +02:00
lucaronin
33324a5c79 fix: harden mcp bridge and vault boundaries 2026-04-22 17:14:42 +02:00
lucaronin
f28626bbfe fix: finish claude agent streams 2026-04-22 15:59:12 +02:00
lucaronin
1bc2e3d1e0 docs: add ADRs for startup and linkify lifecycles (guard — from commits 59268acd, bf442646) 2026-04-22 15:09:45 +02:00
lucaronin
8e6bdab3e6 docs: ratchet codescene thresholds 2026-04-22 00:31:54 +02:00
lucaronin
d52362ad9b fix: repair tiptap patch integrity 2026-04-22 00:21:46 +02:00
lucaronin
bf442646f5 fix: silence duplicate linkify init warnings 2026-04-22 00:05:32 +02:00
120 changed files with 8286 additions and 1732 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.9
AVERAGE_THRESHOLD=9.77
HOTSPOT_THRESHOLD=9.94
AVERAGE_THRESHOLD=9.81

View File

@@ -68,6 +68,10 @@ pnpm tauri dev
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 📚 [ADRs](docs/adr) — Architecture Decision Records
## Security
If you believe you have found a security issue, please report it privately as described in [SECURITY.md](./SECURITY.md).
## License
Tolaria is licensed under AGPL-3.0-or-later. The Tolaria name and logo remain covered by the projects trademark policy.

55
SECURITY.md Normal file
View File

@@ -0,0 +1,55 @@
# Security Policy
Thanks for helping keep Tolaria safe.
If you believe you have found a security vulnerability, **please do not open a public GitHub issue**. Report it privately instead.
## Supported versions
We currently support security fixes for:
| Version | Supported |
| --- | --- |
| Latest stable release | ✅ |
| `main` branch | Best effort |
| Older releases / prereleases | ❌ |
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Include as much of the following as you can:
- a short description of the issue
- reproduction steps or a proof of concept
- affected version / commit, if known
- impact assessment
- any suggested mitigation
If the issue involves sensitive user data, credentials, or a working exploit, keep the report private and do not post details publicly.
## What to expect
We will try to:
- acknowledge receipt within a few business days
- reproduce and assess the report
- work on a fix or mitigation if the issue is valid
- coordinate public disclosure after users have had a reasonable chance to update
## Disclosure guidelines
Please give us a reasonable amount of time to investigate and ship a fix before publishing details.
We appreciate responsible disclosure and good-faith research.
## Out of scope
The following are generally out of scope unless they demonstrate a real security impact:
- missing best-practice headers or hardening with no practical exploit
- self-XSS or editor behavior that requires unrealistic user actions
- reports that only affect unsupported old builds
- purely theoretical issues with no plausible attack path
If you are unsure whether something qualifies, please still report it privately.

View File

@@ -153,7 +153,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
└── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -239,7 +239,7 @@ Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -266,6 +266,14 @@ type SidebarSelection =
| { kind: 'view'; filename: string }
```
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -301,6 +309,8 @@ type SidebarSelection =
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, and folder mutations cannot step outside the active vault.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:

View File

@@ -176,7 +176,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -211,7 +211,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json`
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
#### Agent Event Flow
@@ -305,13 +305,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
### Auto-Registration
### Explicit External Tool Setup
On app startup, Tolaria automatically registers itself as an MCP server in:
Tolaria can register itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
### Architecture
@@ -330,7 +330,7 @@ flowchart TD
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI -->|"auto-register"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
```
### WebSocket Bridge
@@ -357,10 +357,11 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs on explicit user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code and Cursor configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -381,6 +382,8 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
### Three Cache Strategies
```mermaid
@@ -486,7 +489,7 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: run_startup_tasks()<br/>(register MCP)
T->>T: run_startup_tasks()<br/>(migrate + seed only)
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
T->>A: App mounts
@@ -495,10 +498,10 @@ sequenceDiagram
A-->>U: WelcomeScreen
else Vault found
A->>VL: useVaultLoader fires
VL->>T: invoke('list_vault') → scan_vault_cached()
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
T-->>VL: VaultEntry[]
VL->>T: invoke('get_modified_files')
VL->>T: useMcpStatus — register if needed
A->>T: useMcpStatus — check explicit MCP setup state
VL-->>A: entries ready
end
@@ -583,7 +586,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `rename.rs` | `rename_note` renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
@@ -599,7 +602,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `mcp.rs` | MCP server spawning + config registration |
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
| `commands/` | Tauri command handlers (split into submodules) |
| `settings.rs` | App settings persistence |
| `vault_config.rs` | Per-vault UI config |
@@ -616,11 +619,15 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
| `create_vault_folder` | Create a folder relative to the active vault root |
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
| `batch_archive_notes` | Archive multiple notes |
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
@@ -679,8 +686,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
| `check_mcp_status` | Check MCP registration state |
| `register_mcp_tools` | Register MCP in Claude/Cursor config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor config |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
### Settings & Config
@@ -728,7 +738,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming with wikilink update |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
@@ -759,7 +770,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:

View File

@@ -111,7 +111,7 @@ tolaria/
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
│ │ ├── useMcpStatus.ts # MCP registration status
│ │ ├── useMcpStatus.ts # Explicit external AI tool connection status + connect/disconnect actions
│ │ ├── useUpdater.ts # In-app updates
│ │ └── ...
│ │
@@ -165,7 +165,7 @@ tolaria/
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── mcp.rs # MCP server lifecycle + registration
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
│ │ ├── settings.rs # App settings persistence
│ │ ├── vault_config.rs # Per-vault UI config
@@ -234,7 +234,7 @@ tolaria/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
@@ -389,4 +389,4 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs`
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex on the normal approval/sandbox path unless you are intentionally designing an advanced mode)

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0072"
title: "Confirmed vault paths gate startup state"
status: active
date: 2026-04-22
---
## Context
Tolaria's startup path was assuming that any incoming `vaultPath` was authoritative immediately. In practice, boot can pass through transient empty paths and stale paths that no longer correspond to the persisted active vault. That produced two classes of regressions:
1. `useVaultLoader` fired `reload_vault` and `get_modified_files` before a real vault path existed, generating avoidable warnings and backend calls for `""`.
2. On fresh install or other non-persisted startup cases, a missing path could incorrectly render `vault-missing` instead of the intended welcome flow.
Tolaria's onboarding and vault-loading surfaces need the same invariant: only a confirmed vault identity should drive startup side effects or missing-vault error UI.
## Decision
**Tolaria now treats a vault path as authoritative at startup only after it is confirmed.** Vault-loading side effects no-op until the path is non-empty, and the `vault-missing` onboarding state is shown only when the missing path was the persisted active vault recorded in `load_vault_list`. Otherwise, startup falls back to `welcome`.
## Options considered
- **Option A** (chosen): gate startup effects and missing-vault UI on confirmed vault identity. This keeps boot deterministic, avoids empty-path backend calls, and preserves the product rule that fresh installs should land in Welcome rather than an error state.
- **Option B**: treat any startup `vaultPath` as authoritative immediately. Simpler branching, but it keeps the existing race where transient or stale paths trigger warnings and the wrong onboarding state.
- **Option C**: special-case each startup surface independently. Lower immediate churn, but it would duplicate boot logic and let `useOnboarding` and `useVaultLoader` drift again.
## Consequences
- `useVaultLoader` must guard all startup work behind a real non-empty vault path.
- `useOnboarding` must consult persisted vault state before deciding that a missing path represents a deleted active vault.
- Fresh installs, cleared vault lists, and other startup flows without a confirmed active vault should resolve to `welcome`, even if an initial path probe fails.
- Re-evaluate if Tolaria introduces deeper startup routing (for example multiple launch intents or restored workspaces) that needs a richer boot-state model than the current confirmed-path gate.

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0073"
title: "Persistent linkify protocol registry across editor remounts"
status: active
date: 2026-04-22
---
## Context
Tolaria keeps a single editor shell alive while users swap notes and toggle between BlockNote and raw mode. The upstream BlockNote/Tiptap link stack assumes linkify protocol registration is effectively one-shot per editor lifetime, and Tiptap's link extension resets that registry on destroy.
In Tolaria's lifecycle, that behavior caused duplicate `linkifyjs: already initialized` warnings during note-open and editor-remount flows. The problem is cross-cutting: BlockNote and Tiptap both participate, and the failure only disappears when protocol registration survives teardown/remount cycles instead of being repeated opportunistically.
## Decision
**Tolaria patches the upstream BlockNote and Tiptap link packages so custom linkify protocols are pre-registered once per app runtime and are not reset on editor teardown.** The patched packages coordinate through `globalThis` flags, and Tolaria tracks them via `pnpm` patched dependencies rather than ad hoc runtime monkey-patching inside app code.
## Options considered
- **Option A** (chosen): maintain explicit `pnpm` patches for the affected upstream packages, pre-register the needed protocols once, and preserve the registry across remounts. This matches Tolaria's persistent editor shell and keeps the behavior deterministic in both dev and packaged builds.
- **Option B**: keep upstream behavior and tolerate or suppress the warnings locally. Lower maintenance, but it leaves editor lifecycle correctness dependent on noisy duplicate initialization and makes future regressions harder to reason about.
- **Option C**: add Tolaria-side runtime monkey-patches around editor mount/unmount. Avoids vendoring patches, but spreads dependency-specific lifecycle logic into application code and is more fragile across package upgrades.
## Consequences
- `pnpm-workspace.yaml` now treats the relevant BlockNote and Tiptap link packages as patched dependencies, so upgrades must preserve or consciously replace those patches.
- Editor teardown in Tolaria must not assume ownership of the global linkify protocol registry.
- Smoke coverage for note open, editor remount, and raw-mode toggling must stay in place because the failure mode is lifecycle-specific rather than feature-specific.
- Re-evaluate this ADR if upstream BlockNote/Tiptap gains a supported lifecycle-safe protocol-registration model that makes the Tolaria patches unnecessary.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0074"
title: "Explicit external AI tool setup and least-privilege desktop scope"
status: active
date: 2026-04-22
---
## Context
Tolaria's first MCP integration optimized for zero setup: desktop startup auto-registered the Tolaria MCP server in Claude Code and Cursor config files, the Tauri asset protocol allowed every local path, and app-managed Codex sessions launched with the CLI's dangerous bypass flag. That made the product feel convenient, but it also widened trust by default in places that users could not see or consent to clearly.
The product direction now favors least-privilege defaults. Fresh installs should not silently edit third-party config files, external AI tool setup must be intentional and reversible, and the desktop shell should only expose the filesystem paths that the active vault actually needs.
## Decision
**Tolaria now treats external AI tool wiring as an explicit user action and keeps the desktop shell scoped to the active vault.**
- The app still spawns its local MCP WebSocket bridge on desktop startup, but it no longer auto-registers third-party MCP config files.
- External MCP registration is exposed through a keyboard-accessible setup flow reachable from the command palette and status surfaces. Confirming the flow upserts Tolaria's MCP entry for the current vault; cancel leaves external config untouched; disconnect removes Tolaria's entry again.
- The Tauri asset protocol remains enabled for local vault images, but its static config scope is empty. Tolaria grants recursive asset access only to the active vault at runtime when that vault is reloaded.
- App-managed Codex sessions use the CLI's normal approval and sandbox path by default instead of opting into the dangerous bypass mode automatically.
## Options considered
- **Explicit setup + runtime vault-only scope** (chosen): aligns with least-privilege defaults, keeps command-palette discoverability, preserves image loading and external-tool support, and makes every privileged step visible and reversible.
- **Keep startup auto-registration and global asset scope**: lowest friction, but it silently mutates third-party config and leaves the desktop shell effectively open to every local file path.
- **Disable external MCP registration entirely**: safest on paper, but it removes a valuable workflow for Claude Code, Cursor, and other MCP-compatible tools that Tolaria intentionally supports.
## Consequences
- Fresh installs no longer modify `~/.claude/mcp.json` or `~/.cursor/mcp.json` until the user confirms setup.
- Switching vaults does not silently retarget external MCP clients; users reconnect explicitly when they want a different vault exposed.
- Desktop asset access is constrained to the active vault instead of all filesystem paths, while note images and attachments continue to load normally.
- The command palette and status bar now expose an explicit external AI tools setup/remove flow that supports keyboard-only QA.
- Codex agent sessions are safer by default, at the cost of relying on the CLI's normal approval path instead of bypassing it automatically.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0075"
title: "Crash-safe note rename transactions"
status: active
date: 2026-04-22
---
## Context
Tolaria's note rename path used a simple "write new file, then delete old file" flow. That was easy to implement, but it had three integrity problems called out by issue #205: it could leave a visible duplicate note if the app crashed between those steps, destination-path selection depended on check-then-use races, and backlink rewrite failures were collapsed into a generic updated-files count that made partial success look clean.
Rename is a core vault integrity operation. The app needs a flow that preserves a trustworthy visible state even when the process dies mid-rename, and it needs to surface any partial backlink rewrite failures clearly enough that users are not told everything updated when some linked files still need manual repair.
## Decision
**Tolaria now stages note renames through a hidden per-vault transaction directory and recovers unfinished transactions on the next vault scan.**
- A rename that changes the file path first writes a transaction manifest plus a hidden backup path inside `<vault>/.tolaria-rename-txn/`.
- Tolaria moves the old note into that hidden backup, persists the new file with a no-clobber destination write, and then deletes the backup/manifest only after the new note exists.
- If the process crashes before the new note is committed, the next `scan_vault` restores the hidden backup back to the original path before listing entries.
- Manual filename renames keep their explicit conflict semantics, but the final destination is now claimed with a no-clobber write instead of relying on an existence check.
- Backlink rewrites now return both the number of successful updates and the number of failed updates so the UI can warn about partial success instead of reporting a clean rename.
## Options considered
- **Hidden transaction directory + scan-time recovery** (chosen): keeps in-flight rename artifacts out of the visible vault model, gives Tolaria a deterministic recovery point after crashes, and lets the final destination use no-clobber persistence.
- **Rename in place without transaction metadata**: simpler, but it cannot recover a half-finished rename reliably after process death and still leaves either duplicate or missing-note windows.
- **Best-effort duplicate cleanup with no recovery path**: lowest implementation cost, but it leaves the user-visible vault state dependent on exact crash timing and does not meet the trustworthiness goal for rename operations.
## Consequences
- Every vault can now contain a hidden `.tolaria-rename-txn/` directory managed by Tolaria; scan and folder UI continue to ignore it because hidden directories are already excluded.
- Rename results are richer: the frontend must treat `failed_updates > 0` as a warning state even when the rename itself succeeded.
- Future changes to vault scanning or note rename behavior must preserve transaction recovery before entry listing, otherwise crash safety regresses.
- The rename path no longer silently overwrites a destination discovered via a stale existence check; title-driven renames retry with suffixed filenames, while explicit filename renames fail cleanly on collision.

View File

@@ -0,0 +1,34 @@
---
type: ADR
id: "0076"
title: "Note retargeting separates type changes from folder moves"
status: active
date: 2026-04-22
---
## Context
ADR-0025 made `type:` the canonical classification field, and ADR-0033 reopened subfolders as a valid way to organize files in the vault. Once Tolaria exposed both type sections and the folder tree in the sidebar, note reorganization had an unresolved ambiguity: does retargeting a note mean changing its semantic type, moving its file, or both?
Without an explicit model, drag-and-drop and command-palette flows would need to duplicate their own validation and persistence logic, and Tolaria could easily drift back toward the old type-folder coupling that ADR-0006 deliberately removed.
## Decision
**Tolaria treats note retargeting as one shared interaction model with two distinct mutation paths: types change metadata, folders change file paths.**
- Retargeting a note to a type updates only the note's `type:` frontmatter. The file stays where it is.
- Retargeting a note to a folder preserves the current filename and `type:` value, and moves the file through the same crash-safe rename transaction pipeline used for backend rename commands.
- Drag/drop targets and command-palette actions both route through the same frontend retargeting abstraction so validation, dialogs, collision handling, and success/error behavior stay consistent.
## Options considered
- **Shared retargeting model with separate type-vs-folder semantics** (chosen): preserves ADR-0025/ADR-0006's decoupling of type from path, lets folder moves reuse ADR-0075's crash-safe rename guarantees, and keeps multiple UI surfaces behaviorally aligned.
- **Treat folders as the source of truth for note type**: simpler mental model for some vaults, but it reintroduces path-based type inference and makes type changes depend on file moves again.
- **Implement drag/drop and command-palette retargeting as separate flows**: lower short-term coordination cost, but it duplicates mutation rules and makes consistency regressions likely.
## Consequences
- Type sections are semantic targets only; they must never imply a filesystem move.
- Folder targets are physical move operations; they must preserve filename/title behavior, reject collisions, and rewrite path-based wikilinks through the shared rename pipeline.
- Future note-retargeting surfaces should reuse the shared retargeting abstraction instead of introducing another mutation path.
- Re-evaluate this ADR if Tolaria later supports bulk retargeting, folder rules that intentionally infer type, or another organization primitive that needs different semantics.

View File

@@ -66,7 +66,7 @@ proposed → active → superseded
| [0008](0008-underscore-system-properties.md) | Underscore convention for system properties | active |
| [0009](0009-keyword-only-search.md) | Keyword-only search (remove semantic indexing) | active |
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
@@ -127,3 +127,8 @@ proposed → active → superseded
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |
| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active |
| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active |
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |

View File

@@ -6,8 +6,10 @@ import os from 'node:os'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
@@ -79,6 +81,17 @@ describe('getNote', () => {
{ code: 'ENOENT' }
)
})
it('should reject absolute paths outside the vault', async () => {
await assertRejectsOutsideVault('laputa-mcp-outside-', outsideNote => outsideNote)
})
it('should reject traversal paths outside the vault', async () => {
await assertRejectsOutsideVault(
'laputa-mcp-traversal-',
outsideNote => path.relative(tmpDir, outsideNote),
)
})
})
describe('searchNotes', () => {
@@ -142,3 +155,53 @@ describe('vaultContext', () => {
assert.equal(ctx.noteCount, 3)
})
})
describe('evaluateBridgeRequest', () => {
it('accepts loopback UI requests from trusted origins', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: 'http://localhost:5202',
remoteAddress: '127.0.0.1',
}),
{ ok: true, reason: null },
)
})
it('rejects browser origins on the tool bridge', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'tool',
origin: 'https://evil.example',
remoteAddress: '127.0.0.1',
}),
{ ok: false, reason: 'browser origins are not allowed on the tool bridge' },
)
})
it('rejects non-loopback clients even without an origin', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: undefined,
remoteAddress: '192.168.1.10',
}),
{ ok: false, reason: 'non-local client' },
)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')
try {
await fs.writeFile(outsideNote, '# Outside\n')
await assert.rejects(
() => getNote(tmpDir, resolveNotePath(outsideNote)),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await fs.rm(outsideDir, { recursive: true, force: true })
}
}

View File

@@ -6,6 +6,8 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
/**
* Recursively find all .md files under a directory.
* @param {string} dir
@@ -15,17 +17,28 @@ export async function findMarkdownFiles(dir) {
const results = []
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
} else if (item.name.endsWith('.md')) {
results.push(full)
}
await collectMarkdownFile(results, dir, item)
}
return results
}
async function resolveVaultNotePath(vaultPath, notePath) {
const vaultRoot = await fs.realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
const noteRealPath = await fs.realpath(requestedPath)
const relativePath = path.relative(vaultRoot, noteRealPath)
if (!isVaultRelativePath(relativePath)) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return {
vaultRoot,
noteRealPath,
relativePath,
}
}
/**
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
@@ -33,11 +46,14 @@ export async function findMarkdownFiles(dir) {
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function getNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const {
noteRealPath,
relativePath,
} = await resolveVaultNotePath(vaultPath, notePath)
const raw = await fs.readFile(noteRealPath, 'utf-8')
const parsed = matter(raw)
return {
path: path.relative(vaultPath, absPath),
path: relativePath,
frontmatter: parsed.data,
content: parsed.content.trim(),
}
@@ -59,18 +75,15 @@ export async function searchNotes(vaultPath, query, limit = 10) {
if (results.length >= limit) break
const content = await fs.readFile(filePath, 'utf-8')
const filename = path.basename(filePath, '.md')
const titleMatch = extractTitle(content, filename)
const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q)
if (!matchesSearchQuery(titleMatch, content, q)) continue
if (matches) {
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
return results
@@ -88,47 +101,92 @@ export async function vaultContext(vaultPath) {
const notesWithMtime = []
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
const { topFolder, note, type } = await readVaultContextNote(vaultPath, filePath)
if (type) typesSet.add(type)
const rel = path.relative(vaultPath, filePath)
const topFolder = rel.split(path.sep)[0]
if (topFolder !== rel) foldersSet.add(topFolder + '/')
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
})
if (topFolder) foldersSet.add(topFolder)
notesWithMtime.push(note)
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
// Read config files for AI agent context
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
configFiles.agents = agentsContent
} catch {
// config/agents.md may not exist yet
}
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles,
configFiles: await readConfigFiles(vaultPath),
vaultPath,
}
}
// --- Helpers ---
async function collectMarkdownFile(results, dir, item) {
if (item.name.startsWith('.')) return
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
return
}
if (item.name.endsWith('.md')) {
results.push(full)
}
}
function resolveRequestedNotePath(vaultRoot, notePath) {
if (path.isAbsolute(notePath)) return notePath
return path.resolve(vaultRoot, notePath)
}
function isVaultRelativePath(relativePath) {
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
function matchesSearchQuery(title, content, query) {
return title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
async function readVaultContextNote(vaultPath, filePath) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const rel = path.relative(vaultPath, filePath)
const topFolder = extractTopFolder(rel)
const stat = await fs.stat(filePath)
const type = parsed.data.type || parsed.data.is_a || null
return {
topFolder,
type,
note: {
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
},
}
}
function extractTopFolder(relativePath) {
const topFolder = relativePath.split(path.sep)[0]
return topFolder === relativePath ? null : `${topFolder}/`
}
async function readConfigFiles(vaultPath) {
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
} catch {
// config/agents.md may not exist yet
}
return configFiles
}
/**
* Extract title from markdown content (first H1 or frontmatter title).
* @param {string} content

View File

@@ -28,6 +28,12 @@ import {
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const LOOPBACK_HOST = 'localhost'
const TRUSTED_UI_ORIGINS = new Set([
'tauri://localhost',
'http://tauri.localhost',
'https://tauri.localhost',
])
/** @type {WebSocketServer | null} */
let uiBridge = null
@@ -71,6 +77,52 @@ async function handleMessage(data) {
}
}
export function isLoopbackAddress(remoteAddress) {
return remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
|| remoteAddress === '::ffff:127.0.0.1'
}
export function isTrustedUiOrigin(origin) {
if (!origin) return true
if (TRUSTED_UI_ORIGINS.has(origin)) return true
return /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/u.test(origin)
}
export function evaluateBridgeRequest({ bridgeType, origin, remoteAddress }) {
if (!isLoopbackAddress(remoteAddress)) {
return { ok: false, reason: 'non-local client' }
}
if (bridgeType === 'tool' && origin) {
return { ok: false, reason: 'browser origins are not allowed on the tool bridge' }
}
if (bridgeType === 'ui' && !isTrustedUiOrigin(origin)) {
return { ok: false, reason: 'untrusted UI origin' }
}
return { ok: true, reason: null }
}
function verifyBridgeRequest(bridgeType) {
return (info, done) => {
const verdict = evaluateBridgeRequest({
bridgeType,
origin: info.origin,
remoteAddress: info.req.socket.remoteAddress,
})
if (!verdict.ok) {
console.error(`[ws-bridge] Rejected ${bridgeType} bridge client: ${verdict.reason}`)
done(false, 403, 'Forbidden')
return
}
done(true)
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
@@ -89,8 +141,11 @@ export function startUiBridge(port = WS_UI_PORT) {
resolve(null)
})
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
httpServer.listen(port, LOOPBACK_HOST, () => {
const wss = new WebSocketServer({
server: httpServer,
verifyClient: verifyBridgeRequest('ui'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
@@ -109,7 +164,11 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
verifyClient: verifyBridgeRequest('tool'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
@@ -126,7 +185,7 @@ export function startBridge(port = WS_PORT) {
ws.on('close', () => console.error('[ws-bridge] Client disconnected'))
})
console.error(`[ws-bridge] Listening on ws://localhost:${port}`)
console.error(`[ws-bridge] Listening on ws://${LOOPBACK_HOST}:${port}`)
return wss
}

View File

@@ -14,7 +14,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -11,3 +11,50 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceac
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
--- a/src/editor/managers/ExtensionManager/extensions.ts
+++ b/src/editor/managers/ExtensionManager/extensions.ts
@@ -84,7 +84,8 @@ export function getDefaultTiptapExtensions(
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS,
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: [],
}),
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
diff --git a/dist/blocknote.js b/dist/blocknote.js
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@ const de = () => {
]
})
);
-let fe = !1;
+const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered";
+const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis;
+let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]);
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2063,8 +2065,9 @@ function mn(o, e) {
inclusive: !1
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: fe ? [] : Bt
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: []
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2115,7 @@ function mn(o, e) {
),
Do(o)
];
- return fe = !0, t;
+ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;
}
function kn(o, e) {
const t = [

View File

@@ -0,0 +1,101 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index b7357fa..c2c59cb 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -36,6 +36,16 @@ var import_core = require("@tiptap/core");
var import_state = require("@tiptap/pm/state");
var import_linkifyjs = require("linkifyjs");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ (0, import_linkifyjs.registerCustomProtocol)(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/whitespace.ts
var UNICODE_WHITESPACE_PATTERN = "[\0- \xA0\u1680\u180E\u2000-\u2029\u205F\u3000]";
var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
@@ -253,7 +263,8 @@ var Link = import_core3.Mark.create({
});
},
onDestroy() {
- (0, import_linkifyjs3.reset)();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -472,4 +483,4 @@ var index_default = Link;
isAllowedUri,
pasteRegex
});
-//# sourceMappingURL=index.cjs.map
\ No newline at end of file
+//# sourceMappingURL=index.cjs.map
diff --git a/dist/index.js b/dist/index.js
index d486894..cb8e256 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -13,6 +13,16 @@ var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
var UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`);
var UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, "g");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/autolink.ts
function isValidLinkStructure(tokens) {
if (tokens.length === 1) {
@@ -224,7 +234,8 @@ var Link = Mark.create({
});
},
onDestroy() {
- reset();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -443,4 +454,4 @@ export {
isAllowedUri,
pasteRegex
};
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+//# sourceMappingURL=index.js.map
diff --git a/src/link.ts b/src/link.ts
index 839a575..bbef783 100644
--- a/src/link.ts
+++ b/src/link.ts
@@ -8,6 +8,20 @@ import { clickHandler } from './helpers/clickHandler.js'
import { pasteHandler } from './helpers/pasteHandler.js'
import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
+const PRE_REGISTERED_PROTOCOLS = ['tel', 'callto', 'sms', 'cid', 'xmpp'] as const
+const linkifyProtocolFlag = '__tolariaTiptapLinkifyProtocolsRegistered'
+const linkifyGlobalObject = typeof globalThis === 'undefined'
+ ? undefined
+ : (globalThis as Record<string, boolean | undefined>)
+
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol)
+ })
+
+ linkifyGlobalObject[linkifyProtocolFlag] = true
+}
+
export interface LinkProtocolOptions {
/**
* The protocol scheme to be registered.

23
pnpm-lock.yaml generated
View File

@@ -6,11 +6,14 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
hash: 04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
path: patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0':
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
path: patches/@tiptap__extension-link@3.19.0.patch
prosemirror-tables@1.8.5:
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
path: patches/prosemirror-tables@1.8.5.patch
@@ -24,10 +27,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4511,9 +4514,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -4521,7 +4524,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -4532,7 +4535,7 @@ snapshots:
'@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-link': 3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
@@ -4573,7 +4576,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -4595,7 +4598,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
@@ -6288,7 +6291,7 @@ snapshots:
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
'@tiptap/extension-link@3.19.0(patch_hash=fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0

View File

@@ -7,4 +7,5 @@ ignoredBuiltDependencies:
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0': patches/@tiptap__extension-link@3.19.0.patch
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch

View File

@@ -37,6 +37,6 @@ tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
[dev-dependencies]
tempfile = "3"

View File

@@ -246,7 +246,6 @@ fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, Strin
Ok(vec![
"exec".into(),
"--json".into(),
"--dangerously-bypass-approvals-and-sandbox".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
@@ -377,8 +376,8 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
crate::claude_cli::ClaudeStreamEvent::Error { message } => {
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done
| crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -406,6 +405,20 @@ mod tests {
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
@@ -460,4 +473,11 @@ mod tests {
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,14 +1,15 @@
use crate::vault;
use super::expand_tilde;
use super::vault::VaultBoundary;
#[tauri::command]
pub async fn batch_delete_notes_async(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths
.iter()
.map(|path| expand_tilde(path).into_owned())
.collect();
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&expanded))
pub async fn batch_delete_notes_async(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<Vec<String>, String> {
let boundary = VaultBoundary::from_request(vault_path.as_deref())?;
let validated_paths = boundary.validate_existing_paths(&paths)?;
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&validated_paths))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}

View File

@@ -0,0 +1,23 @@
use crate::vault::{self, FolderRenameResult};
use super::expand_tilde;
#[tauri::command]
pub fn rename_vault_folder(
vault_path: String,
folder_path: String,
new_name: String,
) -> Result<FolderRenameResult, String> {
let vault_path = expand_tilde(&vault_path);
vault::rename_folder(
std::path::Path::new(vault_path.as_ref()),
&folder_path,
&new_name,
)
}
#[tauri::command]
pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
}

View File

@@ -1,5 +1,6 @@
mod ai;
mod delete;
mod folders;
mod git;
mod git_connect;
mod system;
@@ -10,6 +11,7 @@ use std::borrow::Cow;
pub use ai::*;
pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use system::*;

View File

@@ -26,8 +26,17 @@ pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
pub async fn remove_mcp_tools() -> Result<String, String> {
tokio::task::spawn_blocking(crate::mcp::remove_mcp)
.await
.map_err(|e| format!("Removal task failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatus, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::check_mcp_status(&vault_path))
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
@@ -42,7 +51,13 @@ pub async fn register_mcp_tools(_vault_path: String) -> Result<String, String> {
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
pub async fn remove_mcp_tools() -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}

View File

@@ -1,321 +1,36 @@
use crate::frontmatter::FrontmatterValue;
use crate::search::SearchResponse;
use crate::vault::{
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
};
use crate::{frontmatter, git, search, vault};
mod boundary;
mod file_cmds;
mod frontmatter_cmds;
mod lifecycle_cmds;
mod rename_cmds;
mod scan_cmds;
mod view_cmds;
use super::expand_tilde;
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn list_vault_folders(path: String) -> Result<Vec<FolderNode>, String> {
let path = expand_tilde(&path);
vault::scan_vault_folders(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::auto_rename_untitled(&vault_path, &note_path)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths.iter().map(|p| expand_tilde(p).into_owned()).collect();
vault::batch_delete_notes(&expanded)
}
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let folder_path =
build_vault_folder_path(std::path::Path::new(vault_path.as_ref()), &folder_name);
ensure_missing_folder(&folder_path, &folder_name)?;
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
fn build_vault_folder_path(vault_root: &std::path::Path, folder_name: &str) -> std::path::PathBuf {
vault_root.join(folder_name)
}
fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = std::path::Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(std::path::Path::new(&path));
vault::scan_vault_cached(std::path::Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
let path = expand_tilde(&path);
vault::reload_entry(std::path::Path::new(path.as_ref()))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
pub fn sync_note_title(path: String) -> Result<bool, String> {
use vault::SyncAction;
let path = expand_tilde(&path);
let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?;
Ok(matches!(action, SyncAction::Updated { .. }))
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── View commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
let path = expand_tilde(&vault_path);
vault::scan_views(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
// ── Repair command ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}
pub(super) use boundary::VaultBoundary;
pub use file_cmds::*;
pub use frontmatter_cmds::*;
pub use lifecycle_cmds::*;
pub use rename_cmds::*;
pub use scan_cmds::*;
pub use view_cmds::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::ViewDefinition;
use std::path::Path;
const ACTIVE_VAULT_PATH_ERROR: &str = super::boundary::ACTIVE_VAULT_PATH_ERROR;
const INVALID_VIEW_FILENAME_ERROR: &str = super::boundary::INVALID_VIEW_FILENAME_ERROR;
fn vault_path_arg(vault_path: &Path) -> Option<std::path::PathBuf> {
Some(vault_path.to_path_buf())
}
fn vault_path_string_arg(vault_path: &Path) -> Option<String> {
Some(vault_path.to_string_lossy().to_string())
}
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
@@ -356,9 +71,13 @@ mod tests {
#[test]
fn test_batch_archive_notes() {
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
let (dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
assert_eq!(
batch_archive_notes(vec![note.to_str().unwrap().to_string()]).unwrap(),
batch_archive_notes(
vec![note.to_str().unwrap().to_string()],
vault_path_string_arg(dir.path()),
)
.unwrap(),
1
);
let content = std::fs::read_to_string(&note).unwrap();
@@ -372,22 +91,102 @@ mod tests {
let note = dir.path().join("test.md");
std::fs::write(&note, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap();
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let entry = reload_vault_entry(note.clone(), vault_path_arg(dir.path())).unwrap();
assert_eq!(entry.title, "Test");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify file on disk
std::fs::write(&note, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap();
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let fresh = reload_vault_entry(note, vault_path_arg(dir.path())).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_vault_entry_nonexistent() {
let result = reload_vault_entry("/nonexistent/path.md".to_string());
let result = reload_vault_entry("/nonexistent/path.md".into(), None);
assert!(result.is_err());
}
#[test]
fn test_get_note_content_rejects_path_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let inside = vault_path.join("inside.md");
let outside_dir = tempfile::TempDir::new().unwrap();
let outside = outside_dir.path().join("outside.md");
std::fs::write(&inside, "# Inside\n").unwrap();
std::fs::write(&outside, "# Outside\n").unwrap();
let err = get_note_content(outside, vault_path_arg(vault_path))
.expect_err("expected out-of-vault read to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_save_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = save_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = create_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal create to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_rejects_escape_path() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "../escape".into())
.expect_err("expected escaping folder path to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
let dir = tempfile::TempDir::new().unwrap();
let definition = ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
};
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
"../escape.yml".to_string(),
definition,
)
.expect_err("expected nested filename to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
}
#[test]
fn test_reload_vault_invalidates_cache_and_rescans() {
let dir = tempfile::TempDir::new().unwrap();
@@ -430,7 +229,7 @@ mod tests {
.output()
.unwrap();
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
let entries = list_vault(vault_path.into()).unwrap();
assert!(!entries[0].archived);
std::fs::write(

View File

@@ -0,0 +1,282 @@
use crate::commands::expand_tilde;
use crate::vault_list;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
pub(crate) const ACTIVE_VAULT_PATH_ERROR: &str = "Path must stay inside the active vault";
const ACTIVE_VAULT_MISMATCH_ERROR: &str = "Vault path must match the active vault";
const ACTIVE_VAULT_UNAVAILABLE_ERROR: &str = "Active vault is not available";
const NO_ACTIVE_VAULT_ERROR: &str = "No active vault selected";
pub(crate) const INVALID_VIEW_FILENAME_ERROR: &str = "Invalid view filename";
#[derive(Clone, Debug)]
struct VaultRootPaths {
requested: PathBuf,
canonical: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct VaultBoundary {
requested_root: PathBuf,
canonical_root: PathBuf,
}
impl VaultBoundary {
pub(crate) fn from_request(requested_vault_path: Option<&str>) -> Result<Self, String> {
let configured_root = if cfg!(test) {
None
} else {
load_configured_active_vault_root()?
};
let requested_root = requested_vault_path
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()?;
let root = match (configured_root, requested_root) {
(Some(configured), Some(requested)) => {
if configured.canonical != requested.canonical {
return Err(ACTIVE_VAULT_MISMATCH_ERROR.to_string());
}
requested
}
(Some(configured), None) => configured,
(None, Some(requested)) => requested,
(None, None) => return Err(NO_ACTIVE_VAULT_ERROR.to_string()),
};
Ok(Self {
requested_root: root.requested,
canonical_root: root.canonical,
})
}
pub(crate) fn requested_root(&self) -> &Path {
&self.requested_root
}
fn requested_root_str(&self) -> String {
path_to_string(&self.requested_root)
}
fn validate_existing_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, false)
}
pub(crate) fn validate_existing_paths(
&self,
raw_paths: &[String],
) -> Result<Vec<String>, String> {
raw_paths
.iter()
.map(|path| self.validate_existing_path(path))
.collect()
}
fn validate_writable_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, true)
}
pub(crate) fn child_path(&self, relative_path: &str) -> Result<PathBuf, String> {
validate_relative_child_path(relative_path)?;
let requested = self.requested_root.join(relative_path);
let canonical = canonicalize_candidate_for_write(&requested)?;
self.ensure_within_root(&canonical)?;
Ok(requested)
}
fn validate_path(&self, raw_path: &str, allow_missing_leaf: bool) -> Result<String, String> {
let requested = self.requested_path(raw_path);
let canonical = if allow_missing_leaf {
canonicalize_candidate_for_write(&requested)?
} else {
requested
.canonicalize()
.map_err(|_| "File does not exist".to_string())?
};
self.ensure_within_root(&canonical)?;
Ok(path_to_string(&requested))
}
fn requested_path(&self, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_tilde(raw_path).into_owned());
if expanded.is_absolute() {
expanded
} else {
self.requested_root.join(expanded)
}
}
fn ensure_within_root(&self, candidate: &Path) -> Result<(), String> {
candidate
.strip_prefix(&self.canonical_root)
.map(|_| ())
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())
}
}
fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
list.active_vault
.as_deref()
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()
}
fn build_vault_root_paths(raw_vault_path: &str) -> Result<VaultRootPaths, String> {
let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned());
let canonical = requested
.canonicalize()
.map_err(|_| ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string())?;
if !canonical.is_dir() {
return Err(ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string());
}
Ok(VaultRootPaths {
requested,
canonical,
})
}
fn canonicalize_candidate_for_write(path: &Path) -> Result<PathBuf, String> {
let (ancestor, tail) = find_existing_ancestor(path)?;
Ok(tail
.into_iter()
.fold(ancestor, |current, segment| current.join(segment)))
}
fn find_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec<OsString>), String> {
let mut current = path;
let mut tail = Vec::new();
loop {
if current.exists() {
let canonical = current
.canonicalize()
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.reverse();
return Ok((canonical, tail));
}
let file_name = current
.file_name()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.push(file_name.to_os_string());
current = current
.parent()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
}
}
fn validate_relative_child_path(relative_path: &str) -> Result<(), String> {
if relative_path.trim().is_empty() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
let path = Path::new(relative_path);
if path.is_absolute() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
if path.components().any(|component| {
matches!(
component,
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
Ok(())
}
pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let path = Path::new(filename);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
}
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn with_boundary<T>(
requested_vault_path: Option<&str>,
action: impl FnOnce(&VaultBoundary) -> Result<T, String>,
) -> Result<T, String> {
let boundary = VaultBoundary::from_request(requested_vault_path)?;
action(&boundary)
}
pub(crate) enum ValidatedPathMode {
Existing,
Writable,
}
pub(crate) fn with_validated_path<T>(
path: &str,
vault_path: Option<&str>,
mode: ValidatedPathMode,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_path = match mode {
ValidatedPathMode::Existing => boundary.validate_existing_path(path)?,
ValidatedPathMode::Writable => boundary.validate_writable_path(path)?,
};
action(&validated_path)
})
}
pub(crate) fn with_existing_paths<T>(
paths: &[String],
vault_path: Option<&str>,
action: impl FnOnce(Vec<String>) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_paths = boundary.validate_existing_paths(paths)?;
action(validated_paths)
})
}
pub(crate) fn with_requested_root<T>(
vault_path: &str,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
action(&requested_root)
})
}
pub(crate) fn with_existing_path_in_requested_vault<T>(
vault_path: &str,
path: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
let validated_path = boundary.validate_existing_path(path)?;
action(&requested_root, &validated_path)
})
}
pub(crate) fn with_view_file<T>(
vault_path: &str,
filename: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
validate_view_filename(filename)?;
let requested_root = boundary.requested_root_str();
action(&requested_root, filename)
})
}

View File

@@ -0,0 +1,170 @@
use crate::commands::expand_tilde;
use crate::vault::{self, FolderNode, VaultEntry};
use std::path::{Path, PathBuf};
use super::boundary::{
with_boundary, with_existing_paths, with_requested_root, with_validated_path, ValidatedPathMode,
};
fn with_note_path<T>(
path: &Path,
vault_path: Option<&Path>,
mode: ValidatedPathMode,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
mode,
|validated_path| action(Path::new(validated_path)),
)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let expanded = expand_tilde(raw_path.as_ref()).into_owned();
action(Path::new(&expanded))
}
fn with_requested_root_path<T>(
vault_path: &Path,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_requested_root(raw_vault_path.as_ref(), action)
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
action,
)
}
#[tauri::command]
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
vault::get_note_content,
)
}
#[tauri::command]
pub fn save_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::save_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn create_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::create_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn delete_note(path: PathBuf) -> Result<String, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
None,
ValidatedPathMode::Existing,
vault::delete_note,
)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
let raw_paths = paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
with_existing_paths(&raw_paths, None, |validated_paths| {
vault::batch_delete_notes(&validated_paths)
})
}
#[tauri::command]
pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<String, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
let folder_name = folder_name.to_string_lossy();
let folder_path = boundary.child_path(folder_name.as_ref())?;
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name.into_owned())
})
}
fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<bool, String> {
use vault::SyncAction;
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| {
let action = vault::sync_title_on_open(validated_path)?;
Ok(matches!(action, SyncAction::Updated { .. }))
},
)
}
#[tauri::command]
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
vault::save_image(requested_root, &filename, &data)
})
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
})
}
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
}

View File

@@ -0,0 +1,48 @@
use crate::frontmatter;
use crate::frontmatter::FrontmatterValue;
use super::boundary::{with_existing_paths, with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::update_frontmatter(validated_path, &key, value),
)
}
#[tauri::command]
pub fn delete_frontmatter_property(
path: String,
key: String,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::delete_frontmatter_property(validated_path, &key),
)
}
#[tauri::command]
pub fn batch_archive_notes(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<usize, String> {
with_existing_paths(&paths, vault_path.as_deref(), |validated_paths| {
let mut count = 0;
for path in &validated_paths {
frontmatter::update_frontmatter(path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
})
}

View File

@@ -0,0 +1,90 @@
use crate::commands::expand_tilde;
use crate::{git, vault};
use std::path::Path;
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
fn initialize_empty_vault(vault_dir: &Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}

View File

@@ -0,0 +1,108 @@
use crate::commands::expand_tilde;
use crate::vault::{self, DetectedRename, RenameResult};
use std::path::Path;
use super::boundary::{
with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
};
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note(
requested_root,
validated_path,
&new_title,
old_title.as_deref(),
)
},
)
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note_filename(requested_root, validated_path, &new_filename_stem)
},
)
}
#[tauri::command]
pub fn move_note_to_folder(
vault_path: String,
old_path: String,
folder_path: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
let trimmed_folder_path = folder_path.trim();
if trimmed_folder_path.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let folder_absolute_path = Path::new(requested_root).join(trimmed_folder_path);
with_validated_path(
folder_absolute_path.to_string_lossy().as_ref(),
Some(&vault_path),
ValidatedPathMode::Existing,
|validated_folder_path| {
let validated_folder = Path::new(validated_folder_path);
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(
requested_root,
validated_path,
validated_folder_path,
)
},
)
},
)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
with_existing_path_in_requested_vault(
&vault_path,
&note_path,
|requested_root, validated_path| {
vault::auto_rename_untitled(requested_root, validated_path)
},
)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}

View File

@@ -0,0 +1,51 @@
use crate::commands::expand_tilde;
use crate::search::SearchResponse;
use crate::vault::VaultEntry;
use crate::{search, vault};
use std::path::{Path, PathBuf};
use super::boundary::{with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn reload_vault_entry(
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<VaultEntry, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.as_ref().map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::reload_entry(Path::new(validated_path)),
)
}
#[tauri::command]
pub async fn reload_vault(
app_handle: tauri::AppHandle,
path: String,
) -> Result<Vec<crate::vault::VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(Path::new(&path));
vault::scan_vault_cached(Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}

View File

@@ -0,0 +1,37 @@
use crate::vault::{self, ViewDefinition, ViewFile};
use std::path::Path;
use super::boundary::{with_boundary, with_view_file};
#[tauri::command]
pub fn list_views(vault_path: String) -> Result<Vec<ViewFile>, String> {
with_boundary(Some(vault_path.as_str()), |boundary| {
Ok(vault::scan_views(boundary.requested_root()))
})
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::save_view(Path::new(requested_root), validated_filename, &definition)
},
)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::delete_view(Path::new(requested_root), validated_filename)
},
)
}

View File

@@ -13,6 +13,8 @@ pub mod telemetry;
pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
use std::path::Path;
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
@@ -21,6 +23,9 @@ use std::sync::Mutex;
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct ActiveAssetScopeRoot(Mutex<Option<std::path::PathBuf>>);
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -48,12 +53,6 @@ fn run_startup_tasks() {
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
// Register Tolaria MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),
}
}
#[cfg(desktop)]
@@ -148,17 +147,62 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"Failed to resolve asset scope for {}: {e}",
vault_path.display()
)
})?;
let scope = app_handle.asset_protocol_scope();
let state: tauri::State<'_, ActiveAssetScopeRoot> = app_handle.state();
let mut active_root = state
.0
.lock()
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
if active_root.as_ref() == Some(&canonical_vault_path) {
return Ok(());
}
scope
.allow_directory(&canonical_vault_path, true)
.map_err(|e| {
format!(
"Failed to allow asset access for {}: {e}",
canonical_vault_path.display()
)
})?;
if let Some(previous_root) = active_root.as_ref() {
if previous_root != &canonical_vault_path {
let _ = scope.forbid_directory(previous_root, true);
}
}
*active_root = Some(canonical_vault_path);
Ok(())
}
macro_rules! app_invoke_handler {
() => {
tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::move_note_to_folder,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
@@ -198,6 +242,8 @@ macro_rules! app_invoke_handler {
commands::batch_delete_notes_async,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::rename_vault_folder,
commands::delete_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::check_for_app_update,
@@ -215,6 +261,7 @@ macro_rules! app_invoke_handler {
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry,
@@ -248,7 +295,9 @@ pub fn run() {
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoot(Mutex::new(None)));
with_invoke_handler(builder)
.setup(setup_app)

View File

@@ -11,10 +11,8 @@ const LEGACY_MCP_SERVER_NAME: &str = "laputa";
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
/// MCP server files or config are missing for the active vault.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
@@ -116,8 +114,14 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
Ok(child)
}
fn claude_mcp_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json"))
fn mcp_config_paths() -> Vec<PathBuf> {
[
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json")),
dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect()
}
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
@@ -142,6 +146,21 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
};
let Ok(expected) = std::fs::canonicalize(vault_path) else {
return false;
};
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
return false;
};
actual == expected
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
@@ -172,15 +191,7 @@ pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let entry = build_mcp_entry(&index_js, vault_path);
let configs: Vec<PathBuf> = [
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect();
Ok(register_mcp_to_configs(&entry, &configs))
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
/// Insert or update the Tolaria entry in an MCP config file.
@@ -222,29 +233,79 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
fn remove_mcp_from_configs(config_paths: &[PathBuf]) -> String {
let mut removed_any = false;
for config_path in config_paths {
match remove_mcp_from_config(config_path) {
Ok(true) => removed_any = true,
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
if removed_any {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
let mut config: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut("mcpServers") else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcpServers is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove("mcpServers");
}
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(true)
}
pub fn remove_mcp() -> String {
remove_mcp_from_configs(&mcp_config_paths())
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let Some(config_path) = claude_mcp_config_path() else {
return McpStatus::NotInstalled;
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let Some(entry) = read_registered_mcp_entry(&config_path) else {
return McpStatus::NotInstalled;
};
if entry_index_js_exists(&entry) {
/// Returns `Installed` when the Tolaria entry exists for the active vault in
/// Claude Code or Cursor config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
})
}) {
McpStatus::Installed
} else {
McpStatus::NotInstalled
@@ -260,6 +321,12 @@ mod tests {
serde_json::from_str(&raw).unwrap()
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
index_js
}
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
@@ -566,18 +633,79 @@ mod tests {
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
fn remove_mcp_from_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": { "command": "node", "args": ["/index.js"] },
"laputa": { "command": "node", "args": ["/legacy.js"] },
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(removed);
let updated = read_config(&config_path);
assert!(updated["mcpServers"][MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"]["other-server"].is_object());
}
#[test]
fn remove_mcp_from_config_returns_false_when_entry_missing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(!removed);
}
#[test]
fn check_mcp_status_returns_installed_for_matching_vault() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("vault");
std::fs::create_dir_all(&vault_path).unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_targets_vault(&entry, &vault_path));
assert!(entry_index_js_exists(&entry));
}
#[test]
fn entry_targets_vault_requires_matching_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let first_vault = tmp.path().join("vault-a");
let second_vault = tmp.path().join("vault-b");
std::fs::create_dir_all(&first_vault).unwrap();
std::fs::create_dir_all(&second_vault).unwrap();
let entry = serde_json::json!({
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
});
assert!(entry_targets_vault(&entry, &first_vault));
assert!(!entry_targets_vault(&entry, &second_vault));
}
#[test]
@@ -586,7 +714,5 @@ mod tests {
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -367,7 +367,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
let install_mcp = MenuItemBuilder::new("Set Up External AI Tools…")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")

View File

@@ -1,4 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -63,3 +64,25 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
/// Create a new note file without overwriting any existing file.
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
validate_save_path(file_path, path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
}

View File

@@ -0,0 +1,183 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FolderRenameResult {
pub old_path: String,
pub new_path: String,
}
fn normalize_folder_name(next_name: &str) -> Result<String, String> {
let trimmed = next_name.trim();
if trimmed.is_empty() {
return Err("Folder name cannot be empty".to_string());
}
if trimmed == "." || trimmed == ".." || trimmed.contains('/') || trimmed.contains('\\') {
return Err("Invalid folder name".to_string());
}
Ok(trimmed.to_string())
}
fn ensure_relative_folder_path(folder_path: &str) -> Result<PathBuf, String> {
let trimmed = folder_path.trim();
if trimmed.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
return Err("Folder path must be relative to the vault root".to_string());
}
if relative
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("Folder path cannot escape the vault root".to_string());
}
Ok(relative.to_path_buf())
}
fn display_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn rename_folder(
vault_path: &Path,
folder_path: &str,
next_name: &str,
) -> Result<FolderRenameResult, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let normalized_name = normalize_folder_name(next_name)?;
let source_path = vault_path.join(&relative_path);
if !source_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !source_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
let current_name = source_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.ok_or_else(|| "Folder path cannot target the vault root".to_string())?;
if current_name == normalized_name {
return Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&relative_path),
});
}
let parent_relative = relative_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let destination_relative = parent_relative.join(&normalized_name);
let destination_path = vault_path.join(&destination_relative);
if destination_path.exists() {
return Err(format!(
"Folder '{}' already exists",
display_relative_path(&destination_relative)
));
}
fs::rename(&source_path, &destination_path)
.map_err(|error| format!("Failed to rename folder: {}", error))?;
Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&destination_relative),
})
}
pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result<String, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let target_path = vault_path.join(&relative_path);
if !target_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !target_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
fs::remove_dir_all(&target_path)
.map_err(|error| format!("Failed to delete folder: {}", error))?;
Ok(display_relative_path(&relative_path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_folder(dir: &TempDir, relative: &str) -> PathBuf {
let path = dir.path().join(relative);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn rename_folder_updates_relative_destination() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects/laputa");
let result = rename_folder(dir.path(), "projects", "work").unwrap();
assert_eq!(
result,
FolderRenameResult {
old_path: "projects".to_string(),
new_path: "work".to_string(),
}
);
assert!(dir.path().join("work/laputa").is_dir());
assert!(!dir.path().join("projects").exists());
}
#[test]
fn rename_folder_rejects_duplicate_sibling() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
make_folder(&dir, "areas");
let error = rename_folder(dir.path(), "projects", "areas").unwrap_err();
assert_eq!(error, "Folder 'areas' already exists");
}
#[test]
fn rename_folder_rejects_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn delete_folder_removes_nested_contents() {
let dir = TempDir::new().unwrap();
let nested = make_folder(&dir, "projects/laputa");
fs::write(nested.join("note.md"), "# Note\n").unwrap();
let deleted_path = delete_folder(dir.path(), "projects").unwrap();
assert_eq!(deleted_path, "projects");
assert!(!dir.path().join("projects").exists());
}
#[test]
fn delete_folder_rejects_missing_folder() {
let dir = TempDir::new().unwrap();
let error = delete_folder(dir.path(), "projects").unwrap_err();
assert_eq!(error, "Folder does not exist: projects");
}
}

View File

@@ -2,12 +2,14 @@ mod cache;
mod config_seed;
mod entry;
mod file;
mod folders;
mod frontmatter;
mod getting_started;
mod image;
mod migration;
mod parsing;
mod rename;
mod rename_transaction;
mod title_sync;
mod trash;
mod views;
@@ -18,12 +20,13 @@ pub use config_seed::{
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
@@ -413,6 +416,14 @@ pub fn scan_vault(
));
}
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
log::warn!(
"Failed to recover pending rename transactions in {}: {}",
vault_path.display(),
err
);
}
let mut entries = Vec::new();
scan_all_files(vault_path, git_dates, &mut entries);

View File

@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/briefing.md");
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
assert!(!path.parent().unwrap().exists());
create_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_rejects_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("briefing.md");
fs::write(&path, "# Existing\n").unwrap();
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
.expect_err("expected create-only write to reject collisions");
assert!(err.contains("already exists"));
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
}

View File

@@ -3,8 +3,10 @@ use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use tempfile::NamedTempFile;
use walkdir::WalkDir;
use super::rename_transaction::RenameWorkspace;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
/// Result of a rename operation
@@ -14,6 +16,14 @@ pub struct RenameResult {
pub new_path: String,
/// Number of other files updated (wiki link replacements)
pub updated_files: usize,
/// Number of linked-note rewrites that failed and need manual attention
pub failed_updates: usize,
}
#[derive(Debug, Default)]
struct WikilinkUpdateSummary {
updated_files: usize,
failed_updates: usize,
}
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
@@ -95,10 +105,10 @@ fn update_wikilinks_in_vault(
old_targets: &[&str],
new_target: &str,
exclude_path: &Path,
) -> usize {
) -> WikilinkUpdateSummary {
let re = match build_wikilink_pattern(old_targets) {
Some(r) => r,
None => return 0,
None => return WikilinkUpdateSummary::default(),
};
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
}
@@ -107,23 +117,29 @@ fn replace_wikilinks_in_files(
files: Vec<std::path::PathBuf>,
re: &Regex,
replacement: &str,
) -> usize {
files
.iter()
.filter(|path| rewrite_wikilinks_in_file(path, re, replacement))
.count()
) -> WikilinkUpdateSummary {
let mut summary = WikilinkUpdateSummary::default();
for path in files.iter() {
match rewrite_wikilinks_in_file(path, re, replacement) {
Ok(true) => summary.updated_files += 1,
Ok(false) => {}
Err(_) => summary.failed_updates += 1,
}
}
summary
}
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> Result<bool, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else {
return false;
return Ok(false);
};
fs::write(path, &new_content).is_ok()
fs::write(path, &new_content)
.map(|_| true)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))
}
/// Extract the value of the `title:` frontmatter field from raw content.
@@ -168,28 +184,26 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
.unwrap_or(abs_path)
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
/// `exclude` is the source file being renamed — it should not be treated as a collision.
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() || dest == exclude {
return dest;
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() || candidate == exclude {
return candidate;
}
counter += 1;
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
super::rename_transaction::recover_pending_rename_transactions(vault)
}
fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(), String> {
staged
.persist(target_path)
.map(|_| ())
.map_err(|e| format!("Failed to replace {}: {}", target_path.display(), e.error))
}
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
let new_path = new_file.to_string_lossy().to_string();
let vault_prefix = format!("{}/", vault.to_string_lossy());
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
RenameResult {
new_path,
updated_files: summary.updated_files,
failed_updates: summary.failed_updates,
}
}
@@ -209,6 +223,66 @@ fn is_invalid_filename_stem(stem: &str) -> bool {
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
}
struct LoadedNote {
content: String,
filename: String,
title: String,
}
fn unchanged_result(path: &str) -> RenameResult {
RenameResult {
new_path: path.to_string(),
updated_files: 0,
failed_updates: 0,
}
}
fn validate_new_title(new_title: &str) -> Result<&str, String> {
let trimmed = new_title.trim();
if trimmed.is_empty() {
return Err("New title cannot be empty".to_string());
}
Ok(trimmed)
}
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
if old_file.exists() {
return Ok(());
}
Err(format!("File does not exist: {}", old_path))
}
fn load_note_for_title_rename(
old_file: &Path,
old_path: &str,
old_title_hint: Option<&str>,
) -> Result<LoadedNote, String> {
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let fm_title = extract_fm_title_value(&content);
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &filename);
Ok(LoadedNote {
content,
filename,
title: old_title_hint.unwrap_or(&extracted_title).to_string(),
})
}
fn persist_title_only_update(
workspace: &RenameWorkspace,
old_file: &Path,
updated_content: &str,
old_path: &str,
) -> Result<RenameResult, String> {
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
Ok(unchanged_result(old_path))
}
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
///
/// When `old_title_hint` is provided it is used instead of extracting the title from
@@ -223,69 +297,47 @@ pub fn rename_note(
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
let new_title = new_title.trim();
if new_title.is_empty() {
return Err("New title cannot be empty".to_string());
}
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let fm_title = extract_fm_title_value(&content);
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let old_title = old_title_hint.unwrap_or(&extracted_title);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
let new_title = validate_new_title(new_title)?;
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
// Check both title and filename: even if the title in content matches,
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
let expected_filename = format!("{}.md", title_to_slug(new_title));
let title_unchanged = old_title == new_title;
let filename_matches = old_filename == expected_filename;
let title_unchanged = loaded.title == new_title;
let filename_matches = loaded.filename == expected_filename;
if title_unchanged && filename_matches {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
return Ok(unchanged_result(old_path));
}
// Update content only if the title actually changed
let updated_content = if title_unchanged {
content.clone()
loaded.content.clone()
} else {
update_note_title_in_content(&content, new_title)
update_note_title_in_content(&loaded.content, new_title)
};
let workspace = RenameWorkspace::new(vault)?;
if filename_matches {
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
}
// Compute new path, handling collisions with numeric suffix
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
if old_file != new_file {
fs::remove_file(old_file)
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
}
// Update wikilinks across the vault
let committed = workspace
.operation(old_path, old_file)
.rename_with_candidates(
workspace.stage_note_content(&updated_content)?,
&expected_filename,
parent_dir,
)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(RenameResult {
new_path: new_path_str,
updated_files,
})
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Rename only the file path stem while preserving title/frontmatter content.
@@ -297,6 +349,8 @@ pub fn rename_note_filename(
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
recover_pending_rename_transactions(vault)?;
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
}
@@ -313,40 +367,73 @@ pub fn rename_note_filename(
let new_filename = format!("{}.md", normalized_stem);
if old_filename == new_filename {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
return Ok(unchanged_result(old_path));
}
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(&new_filename);
if new_file.exists() && new_file != old_file {
return Err("A note with that name already exists".to_string());
}
fs::rename(old_file, &new_file).map_err(|e| {
format!(
"Failed to rename {} to {}: {}",
old_path,
new_file.to_string_lossy(),
e
)
})?;
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let new_path = new_file.to_string_lossy().to_string();
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
Ok(RenameResult {
new_path,
updated_files,
})
/// Move a note into a different folder while preserving its filename and content.
pub fn move_note_to_folder(
vault_path: &str,
old_path: &str,
destination_folder_path: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
let destination_dir = Path::new(destination_folder_path);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
if !destination_dir.exists() {
return Err(format!(
"Folder does not exist: {}",
destination_folder_path
));
}
if !destination_dir.is_dir() {
return Err(format!(
"Folder is not a directory: {}",
destination_folder_path
));
}
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_file = destination_dir.join(&old_filename);
if new_file == old_file {
return Ok(unchanged_result(old_path));
}
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
@@ -455,8 +542,8 @@ pub fn update_wikilinks_for_renames(
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += updated;
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
total_updated += summary.updated_files;
}
Ok(total_updated)
@@ -477,6 +564,29 @@ mod tests {
file.write_all(content.as_bytes()).unwrap();
}
struct RenameTestRequest<'a> {
path: &'a str,
content: &'a str,
new_title: &'a str,
old_title_hint: Option<&'a str>,
}
fn rename_test_note_file(
vault: &Path,
request: RenameTestRequest<'_>,
) -> (std::path::PathBuf, RenameResult) {
create_test_file(vault, request.path, request.content);
let old_path = vault.join(request.path);
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
request.new_title,
request.old_title_hint,
)
.unwrap();
(old_path, result)
}
#[test]
fn test_title_to_slug() {
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
@@ -552,25 +662,6 @@ mod tests {
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_same_title_noop() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
None,
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_rename_note_empty_title_error() {
let dir = TempDir::new().unwrap();
@@ -588,54 +679,81 @@ mod tests {
}
#[test]
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
);
fn test_rename_note_noop_variants() {
for old_title_hint in [None, Some("My Note")] {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let (old_path, result) = rename_test_note_file(
vault,
RenameTestRequest {
path: "note/my-note.md",
content: "# My Note\n\nContent.\n",
new_title: "My Note",
old_title_hint,
},
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro|my review]]"));
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
assert_eq!(result.failed_updates, 0);
}
}
#[test]
fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[weekly-review]] for info.\n",
);
fn test_rename_note_updates_legacy_wikilink_targets() {
struct WikilinkRewriteCase<'a> {
ref_content: &'a str,
note_content: &'a str,
new_title: &'a str,
expected_link: &'a str,
removed_link: Option<&'a str>,
}
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
let cases = [
WikilinkRewriteCase {
ref_content: "# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
note_content: "# Weekly Review\n",
new_title: "Sprint Retro",
expected_link: "[[note/sprint-retro|my review]]",
removed_link: None,
},
WikilinkRewriteCase {
ref_content: "# Ref\n\nSee [[weekly-review]] for info.\n",
note_content: "# Weekly Review\n",
new_title: "Sprint Retro",
expected_link: "[[note/sprint-retro]]",
removed_link: Some("[[weekly-review]]"),
},
WikilinkRewriteCase {
ref_content: "See [[Weekly Review]] for details.\n",
note_content: "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
new_title: "Sprint Retrospective",
expected_link: "[[note/sprint-retrospective]]",
removed_link: Some("[[Weekly Review]]"),
},
];
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains("[[note/sprint-retro]]"));
assert!(!ref_content.contains("[[weekly-review]]"));
for case in cases {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/ref.md", case.ref_content);
let (_old_path, result) = rename_test_note_file(
vault,
RenameTestRequest {
path: "note/weekly-review.md",
content: case.note_content,
new_title: case.new_title,
old_title_hint: None,
},
);
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
assert!(ref_content.contains(case.expected_link));
if let Some(removed_link) = case.removed_link {
assert!(!ref_content.contains(removed_link));
}
}
}
#[test]
@@ -864,6 +982,72 @@ mod tests {
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_move_note_to_folder_preserves_filename_and_updates_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"projects/weekly-review.md",
"---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n",
);
create_test_file(
vault,
"areas/linked.md",
"Reference [[projects/weekly-review]]\n",
);
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
)
.expect("move should succeed");
assert!(result.new_path.ends_with("areas/weekly-review.md"));
assert!(!vault.join("projects/weekly-review.md").exists());
assert!(vault.join("areas/weekly-review.md").exists());
assert_eq!(
fs::read_to_string(vault.join("areas/linked.md")).unwrap(),
"Reference [[areas/weekly-review]]\n"
);
}
#[test]
fn test_move_note_to_folder_noop_when_destination_matches_current_parent() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
let source = vault.join("projects/weekly-review.md");
let result = move_note_to_folder(
vault.to_str().unwrap(),
source.to_str().unwrap(),
vault.join("projects").to_str().unwrap(),
)
.expect("move should noop");
assert_eq!(result.new_path, source.to_string_lossy());
assert!(source.exists());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_move_note_to_folder_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
);
assert_eq!(result.unwrap_err(), "A note with that name already exists");
}
#[test]
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
@@ -909,36 +1093,6 @@ mod tests {
assert!(project_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_without_hint_backward_compatible() {
// Existing behavior: no hint, extracts title from H1
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"See [[Weekly Review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[note/sprint-retrospective]]"));
}
#[test]
fn test_rename_note_does_not_modify_h1() {
// H1 is body content — rename should only update frontmatter title, not H1
@@ -975,22 +1129,53 @@ mod tests {
}
#[test]
fn test_rename_note_hint_same_as_new_title_noop() {
// If old_title_hint == new_title, should be a noop
fn test_replace_wikilinks_in_files_reports_failed_updates() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
create_test_file(vault, "note/ref.md", "See [[Old Note]] for details.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
Some("My Note"),
let pattern = build_wikilink_pattern(&["Old Note"]).unwrap();
let summary = replace_wikilinks_in_files(
vec![vault.join("note/ref.md"), vault.join("note/missing.md")],
&pattern,
"note/new-note",
);
assert_eq!(summary.updated_files, 1);
assert_eq!(summary.failed_updates, 1);
}
#[test]
fn test_recover_pending_rename_transactions_restores_backup_when_new_file_is_missing() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let old_path = vault.join("note/original.md");
let new_path = vault.join("note/renamed.md");
create_test_file(vault, "note/original.md", "# Original\n");
let txn_dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&txn_dir).unwrap();
let backup_path = txn_dir.join("rename-backup.bak");
let manifest_path = txn_dir.join("rename-transaction.json");
fs::rename(&old_path, &backup_path).unwrap();
fs::write(
&manifest_path,
serde_json::json!({
"old_path": old_path.to_string_lossy().to_string(),
"new_path": new_path.to_string_lossy().to_string(),
"backup_path": backup_path.to_string_lossy().to_string(),
})
.to_string(),
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
recover_pending_rename_transactions(vault).unwrap();
assert!(old_path.exists());
assert!(!new_path.exists());
assert!(!backup_path.exists());
assert!(!manifest_path.exists());
}
}

View File

@@ -0,0 +1,287 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
struct RenameTransaction {
old_path: String,
new_path: String,
backup_path: String,
}
pub(super) struct RenameWorkspace {
dir: PathBuf,
}
impl RenameWorkspace {
pub(super) fn new(vault: &Path) -> Result<Self, String> {
let dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&dir).map_err(|e| {
format!(
"Failed to create rename transaction dir {}: {}",
dir.display(),
e
)
})?;
Ok(Self { dir })
}
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
let mut staged = NamedTempFile::new_in(&self.dir)
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
staged
.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
staged
.as_file_mut()
.sync_all()
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
Ok(staged)
}
pub(super) fn operation<'a>(
&self,
old_path: &'a str,
old_file: &'a Path,
) -> RenameOperation<'a> {
RenameOperation {
old_path,
old_file,
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
}
}
}
pub(super) struct CommittedRename {
new_file: PathBuf,
manifest_path: PathBuf,
backup_path: PathBuf,
}
impl CommittedRename {
pub(super) fn new_file(&self) -> &Path {
&self.new_file
}
}
impl Drop for CommittedRename {
fn drop(&mut self) {
let _ = fs::remove_file(&self.backup_path);
let _ = fs::remove_file(&self.manifest_path);
}
}
pub(super) struct RenameOperation<'a> {
old_path: &'a str,
old_file: &'a Path,
backup_path: PathBuf,
manifest_path: PathBuf,
}
impl<'a> RenameOperation<'a> {
pub(super) fn rename_with_candidates(
&self,
staged: NamedTempFile,
desired_filename: &str,
parent_dir: &Path,
) -> Result<CommittedRename, String> {
let mut staged = staged;
for attempt in 0.. {
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
self.prepare(&candidate)?;
match staged.persist_noclobber(&candidate) {
Ok(_) => return Ok(self.committed(candidate)),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
staged = err.file;
self.rollback()?;
}
Err(err) => {
self.rollback()?;
return Err(format!(
"Failed to create {}: {}",
candidate.display(),
err.error
));
}
}
}
unreachable!()
}
pub(super) fn rename_exact(
&self,
staged: NamedTempFile,
new_file: &Path,
) -> Result<CommittedRename, String> {
self.prepare(new_file)?;
match staged.persist_noclobber(new_file) {
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
self.rollback()?;
Err("A note with that name already exists".to_string())
}
Err(err) => {
self.rollback()?;
Err(format!(
"Failed to rename {} to {}: {}",
self.old_path,
new_file.to_string_lossy(),
err.error
))
}
}
}
fn prepare(&self, new_file: &Path) -> Result<(), String> {
self.write_manifest(new_file)?;
self.move_into_backup()
}
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
let transaction = RenameTransaction {
old_path: self.old_path.to_string(),
new_path: new_file.to_string_lossy().to_string(),
backup_path: self.backup_path.to_string_lossy().to_string(),
};
let data = serde_json::to_string(&transaction)
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
fs::write(&self.manifest_path, data).map_err(|e| {
format!(
"Failed to write rename transaction {}: {}",
self.manifest_path.display(),
e
)
})
}
fn move_into_backup(&self) -> Result<(), String> {
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
format!(
"Failed to move {} into rename backup {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})
}
fn rollback(&self) -> Result<(), String> {
if self.backup_path.exists() {
if let Some(parent) = self.old_file.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
format!(
"Failed to restore {} from {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})?;
}
let _ = fs::remove_file(&self.manifest_path);
Ok(())
}
fn committed(&self, new_file: PathBuf) -> CommittedRename {
CommittedRename {
new_file,
manifest_path: self.manifest_path.clone(),
backup_path: self.backup_path.clone(),
}
}
}
fn candidate_filename(filename: &str, attempt: usize) -> String {
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
if attempt == 0 {
return filename.to_string();
}
format!("{}-{}{}", stem, attempt + 1, ext)
}
fn transaction_dir(vault: &Path) -> PathBuf {
vault.join(".tolaria-rename-txn")
}
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
let txn_dir = transaction_dir(vault);
if !txn_dir.exists() {
return Ok(());
}
let entries = fs::read_dir(&txn_dir).map_err(|e| {
format!(
"Failed to read rename transaction dir {}: {}",
txn_dir.display(),
e
)
})?;
for entry in entries {
let path = entry
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(data) = fs::read_to_string(&path) else {
let _ = fs::remove_file(&path);
continue;
};
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
let _ = fs::remove_file(&path);
continue;
};
recover_rename_transaction(&path, transaction)?;
}
Ok(())
}
fn recover_rename_transaction(
manifest_path: &Path,
transaction: RenameTransaction,
) -> Result<(), String> {
let old_path = Path::new(&transaction.old_path);
let new_path = Path::new(&transaction.new_path);
let backup_path = Path::new(&transaction.backup_path);
if !backup_path.exists() {
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if new_path.exists() || old_path.exists() {
let _ = fs::remove_file(backup_path);
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if let Some(parent) = old_path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(backup_path, old_path).map_err(|e| {
format!(
"Failed to recover {} from {}: {}",
old_path.display(),
backup_path.display(),
e
)
})?;
let _ = fs::remove_file(manifest_path);
Ok(())
}

View File

@@ -27,12 +27,19 @@
}
],
"security": {
"csp": null,
"csp": {
"default-src": "'self' ipc: http://ipc.localhost",
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"
},
"assetProtocol": {
"enable": true,
"scope": [
"**"
]
"scope": []
}
}
},

View File

@@ -328,6 +328,7 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
import App from './App'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
describe('App', () => {
@@ -379,6 +380,36 @@ describe('App', () => {
})
})
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
}
mockCommandResults.check_mcp_status = 'installed'
render(<App />)
await waitFor(() => {
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
})
await waitFor(() => {
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
})
act(() => {
window.__laputaTest?.dispatchBrowserMenuCommand?.('vault-install-mcp')
})
await waitFor(() => {
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
})
expect(screen.getByTestId('mcp-setup-dialog')).toBeInTheDocument()
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,

View File

@@ -19,6 +19,9 @@ import { WelcomeScreen } from './components/WelcomeScreen'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
@@ -29,7 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { slugify } from './hooks/useNoteCreation'
import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
@@ -58,9 +61,11 @@ import {
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
@@ -221,6 +226,8 @@ function App() {
const dialogs = useDialogs()
const { showAIChat, toggleAIChat } = dialogs
const [showFeedback, setShowFeedback] = useState(false)
const [showMcpSetupDialog, setShowMcpSetupDialog] = useState(false)
const [mcpDialogAction, setMcpDialogAction] = useState<'connect' | 'disconnect' | null>(null)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
const networkStatus = useNetworkStatus()
@@ -344,44 +351,6 @@ function App() {
return true
}, [handleSetSelection])
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
updateConfig('inbox', {
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
@@ -404,9 +373,38 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
}, [])
const closeMcpSetupDialog = useCallback(() => {
if (mcpDialogAction !== null) return
setShowMcpSetupDialog(false)
}, [mcpDialogAction])
const handleConnectMcp = useCallback(async () => {
setMcpDialogAction('connect')
try {
const didConnect = await connectMcp()
if (didConnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [connectMcp])
const handleDisconnectMcp = useCallback(async () => {
setMcpDialogAction('disconnect')
try {
const didDisconnect = await disconnectMcp()
if (didDisconnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [disconnectMcp])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -714,11 +712,26 @@ function App() {
}
await vault.reloadFolders()
setToastMessage(`Created folder "${name}"`)
return true
} catch (e) {
setToastMessage(`Failed to create folder: ${e}`)
return false
}
}, [resolvedPath, vault, setToastMessage])
const folderActions = useFolderActions({
vaultPath: resolvedPath,
selection: effectiveSelection,
setSelection: handleSetSelection,
setTabs: notes.setTabs,
activeTabPathRef: notes.activeTabPathRef,
handleSwitchTab: notes.handleSwitchTab,
closeAllTabs: notes.closeAllTabs,
reloadVault: vault.reloadVault,
reloadFolders: vault.reloadFolders,
setToastMessage,
})
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
@@ -889,37 +902,40 @@ function App() {
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
const handleCreateType = useCallback(async (name: string) => {
const created = await notes.handleCreateType(name)
if (created) setToastMessage(`Type "${name}" created`)
return created
}, [notes, setToastMessage])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim()
if (!trimmed) return
if (!trimmed) return false
const targetFilename = `${slugify(trimmed)}.md`
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
if (filenameCollision && filenameCollision.isA !== 'Type') {
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
throw new Error(`Type filename collision for ${targetFilename}`)
const plan = planNewTypeCreation({ entries: vault.entries, typeName: trimmed, vaultPath: resolvedPath })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
}
if (!exactType && !slugMatch) {
await notes.createTypeEntrySilent(trimmed)
let resolvedTypeName = plan.status === 'existing' ? plan.entry.title : trimmed
if (plan.status === 'create') {
try {
resolvedTypeName = (await notes.createTypeEntrySilent(trimmed)).title
} catch {
return false
}
}
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
setToastMessage(
resolvedTypeName === missingType
plan.status === 'create' && resolvedTypeName === missingType
? `Type "${resolvedTypeName}" created`
: `Type set to "${resolvedTypeName}"`,
)
}, [notes, setToastMessage, vault.entries])
return true
}, [notes, resolvedPath, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
@@ -1092,10 +1108,60 @@ function App() {
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const noteRetargetingUi = useNoteRetargetingUi({
activeEntry: activeCommandEntry,
activeNoteBlocked: !!activeDeletedFile,
entries: vault.entries,
folders: vault.folders,
selection: effectiveSelection,
setSelection: handleSetSelection,
setToastMessage,
vaultPath: resolvedPath,
updateFrontmatter: notes.handleUpdateFrontmatter,
moveNoteToFolder: notes.handleMoveNoteToFolder,
})
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const shouldBlockNeighborhoodEscape = (
dialogs.showCreateTypeDialog
|| dialogs.showQuickOpen
|| dialogs.showCommandPalette
|| dialogs.showAIChat
|| dialogs.showSettings
|| dialogs.showCloneVault
|| dialogs.showSearch
|| dialogs.showConflictResolver
|| dialogs.showCreateViewDialog
|| noteRetargetingUi.isDialogOpen
|| showFeedback
)
useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
const activeElement = document.activeElement
if (isEditorEscapeTarget(activeElement)) {
event.preventDefault()
activeElement.blur()
requestAnimationFrame(() => {
focusNoteListContainer(document)
})
return
}
if (isEditableElement(activeElement)) return
if (handleNeighborhoodHistoryBack()) {
event.preventDefault()
}
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
@@ -1107,6 +1173,45 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
)
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [])
const toggleRawEditorCommand = useMemo(
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
const restoreVaultAiGuidanceCommand = useCallback(() => {
void restoreVaultAiGuidance()
}, [restoreVaultAiGuidance])
const changeNoteTypeCommand = useMemo(
() => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined,
[noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog],
)
const moveNoteToFolderCommand = useMemo(
() => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined,
[noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog],
)
const activeNoteHasIcon = useMemo(() => {
const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath)
return hasNoteIconValue(entry?.icon)
}, [notes.activeTabPath, vault.entries])
const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined
const canCustomizeNoteListColumns = useMemo(() => (
effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
)
), [effectiveSelection, explicitOrganizationEnabled])
const restoreDeletedNoteCommand = useMemo(
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1114,7 +1219,7 @@ function App() {
visibleNotesRef,
multiSelectionCommandRef,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
activeNoteModified,
selection: effectiveSelection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
@@ -1130,11 +1235,13 @@ function App() {
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
onRenameFolder: folderActions.renameSelectedFolder,
onDeleteFolder: folderActions.deleteSelectedFolder,
showInbox: explicitOrganizationEnabled,
onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -1145,16 +1252,16 @@ function App() {
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRemoveActiveVault: removeActiveVaultCommand,
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onInstallMcp: openMcpSetupDialog,
onOpenAiAgents: dialogs.openSettings,
aiAgentsStatus,
vaultAiGuidanceStatus,
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
onRestoreVaultAiGuidance: restoreVaultAiGuidanceCommand,
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
@@ -1163,23 +1270,19 @@ function App() {
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
activeNoteHasIcon: (() => {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return hasNoteIconValue(ae?.icon)
})(),
onChangeNoteType: changeNoteTypeCommand,
onMoveNoteToFolder: moveNoteToFolderCommand,
canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder,
activeNoteHasIcon,
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
canCustomizeNoteListColumns,
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
onRestoreDeletedNote: restoreDeletedNoteCommand,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -1240,7 +1343,12 @@ function App() {
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
if (
!noteWindowParams
&& onboarding.state.status === 'ready'
&& aiAgentsOnboarding.showPrompt
&& !showMcpSetupDialog
) {
return (
<>
<AiAgentsOnboardingView
@@ -1253,7 +1361,7 @@ function App() {
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) {
return (
<div className="app-shell">
<GitRequiredModal
@@ -1270,135 +1378,157 @@ function App() {
}
return (
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateMissingType={handleCreateMissingType}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={handleTrackedContentChange}
onSave={handleTrackedSave}
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
/>
</div>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
onClose={dialogs.closeCommandPalette}
/>
)}
</div>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<NoteRetargetingDialogs
dialogState={noteRetargetingUi.dialogState}
dialogEntry={noteRetargetingUi.dialogEntry}
typeOptions={noteRetargetingUi.typeOptions}
folderOptions={noteRetargetingUi.folderOptions}
onClose={noteRetargetingUi.closeDialog}
onSelectType={noteRetargetingUi.selectType}
onSelectFolder={noteRetargetingUi.selectFolder}
/>
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog
open={true}
title={deleteActions.confirmDelete.title}
message={deleteActions.confirmDelete.message}
confirmLabel={deleteActions.confirmDelete.confirmLabel}
onConfirm={deleteActions.confirmDelete.onConfirm}
onCancel={() => deleteActions.setConfirmDelete(null)}
/>
)}
{folderActions.confirmDeleteFolder && (
<ConfirmDeleteDialog
open={true}
title={folderActions.confirmDeleteFolder.title}
message={folderActions.confirmDeleteFolder.message}
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
onConfirm={folderActions.confirmDeleteSelectedFolder}
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</NoteRetargetingProvider>
)
}

View File

@@ -46,6 +46,19 @@ describe('CreateTypeDialog', () => {
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('stays open when create reports a handled collision', async () => {
const onClose = vi.fn()
const onCreate = vi.fn().mockResolvedValue(false)
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={onCreate} />)
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Recipe'))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Create New Type')).toBeInTheDocument()
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => {}} />)

View File

@@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input'
interface CreateTypeDialogProps {
open: boolean
onClose: () => void
onCreate: (name: string) => void | Promise<void>
onCreate: (name: string) => boolean | void | Promise<boolean | void>
initialName?: string
}
interface CreateTypeDialogFormProps {
initialName: string
onClose: () => void
onCreate: (name: string) => void | Promise<void>
onCreate: (name: string) => boolean | void | Promise<boolean | void>
}
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
@@ -23,8 +23,8 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
await onCreate(trimmed)
onClose()
const created = await onCreate(trimmed)
if (created !== false) onClose()
}
return (

View File

@@ -239,7 +239,7 @@ export function DynamicPropertiesPanel({
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,

View File

@@ -52,7 +52,7 @@ interface EditorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
@@ -360,7 +360,7 @@ function EditorLayout({
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void

View File

@@ -27,7 +27,7 @@ interface EditorRightPanelProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void

View File

@@ -1,6 +1,8 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { useState } from 'react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions'
import type { FolderNode, SidebarSelection } from '../types'
const mockFolders: FolderNode[] = [
@@ -34,37 +36,191 @@ describe('FolderTree', () => {
expect(screen.getByText('journal')).toBeInTheDocument()
})
it('does not show children initially', () => {
it('expands children when clicking the folder chevron', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
})
it('calls onSelect with folder kind when clicking a folder', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('expands children when clicking a folder with children', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
fireEvent.click(screen.getByLabelText('Expand projects'))
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
})
it('collapses section when clicking FOLDERS header', () => {
it('calls onSelect with folder kind when clicking a folder row', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByTestId('folder-row:projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('expands children when single-clicking a folder row with children', () => {
vi.useFakeTimers()
function FolderTreeHarness() {
const [selection, setSelection] = useState<SidebarSelection>(defaultSelection)
return <FolderTree folders={mockFolders} selection={selection} onSelect={setSelection} />
}
render(<FolderTreeHarness />)
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('collapses section when clicking the FOLDERS header', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.getByText('projects')).toBeInTheDocument()
fireEvent.click(screen.getByText('FOLDERS'))
expect(screen.queryByText('projects')).not.toBeInTheDocument()
})
it('highlights selected folder', () => {
const sel: SidebarSelection = { kind: 'folder', path: 'areas' }
render(<FolderTree folders={mockFolders} selection={sel} onSelect={vi.fn()} />)
const btn = screen.getByText('areas').closest('button')!
expect(btn.className).toContain('text-primary')
it('highlights the selected folder row', () => {
const selection: SidebarSelection = { kind: 'folder', path: 'areas' }
render(<FolderTree folders={mockFolders} selection={selection} onSelect={vi.fn()} />)
expect(screen.getByTestId('folder-row:areas').className).toContain('text-primary')
})
it('opens the create-folder input from the header action', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onCreateFolder={vi.fn().mockResolvedValue(true)}
/>,
)
fireEvent.click(screen.getByTestId('create-folder-btn'))
expect(screen.getByTestId('new-folder-input')).toBeInTheDocument()
})
it('starts rename on folder double-click', () => {
const onStartRenameFolder = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.doubleClick(screen.getByTestId('folder-row:projects'))
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
})
it('shows inline rename and delete actions for folders', () => {
const onDeleteFolder = vi.fn()
const onStartRenameFolder = vi.fn()
const onSelect = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={onSelect}
onDeleteFolder={onDeleteFolder}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('rename-folder-btn:projects'))
fireEvent.click(screen.getByTestId('delete-folder-btn:projects'))
expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'folder', path: 'projects' })
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'folder', path: 'projects' })
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('does not reserve a disclosure slot for leaf folders', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
const parentRowContainer = screen.getByTestId('folder-row:projects').parentElement
expect(leafRowContainer).not.toBeNull()
expect(parentRowContainer).not.toBeNull()
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
})
it('shows the rename input when a folder is being renamed', () => {
render(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'areas' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
renamingFolderPath="areas"
onCancelRenameFolder={vi.fn()}
/>,
)
expect(screen.getByTestId('rename-folder-input')).toBeInTheDocument()
})
it('keeps folder toggling healthy after cancelling rename', () => {
vi.useFakeTimers()
const onCancelRenameFolder = vi.fn()
const { rerender } = render(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'projects' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
renamingFolderPath="projects"
onCancelRenameFolder={onCancelRenameFolder}
/>,
)
fireEvent.keyDown(screen.getByTestId('rename-folder-input'), { key: 'Escape' })
expect(onCancelRenameFolder).toHaveBeenCalledTimes(1)
rerender(
<FolderTree
folders={mockFolders}
selection={{ kind: 'folder', path: 'projects' }}
onSelect={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onCancelRenameFolder={onCancelRenameFolder}
/>,
)
const wasExpanded = screen.queryByText('laputa') !== null
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('laputa') !== null).toBe(!wasExpanded)
vi.useRealTimers()
})
it('opens a context menu with a delete action on right-click', () => {
const onDeleteFolder = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onDeleteFolder={onDeleteFolder}
onStartRenameFolder={vi.fn()}
/>,
)
fireEvent.contextMenu(screen.getByText('projects'))
expect(screen.getByTestId('folder-context-menu')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('delete-folder-menu-item'))
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
})

View File

@@ -1,162 +1,148 @@
import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react'
import {
memo,
useCallback,
} from 'react'
import {
Plus,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import type { FolderNode, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { FolderContextMenu } from './folder-tree/FolderContextMenu'
import { FolderNameInput } from './folder-tree/FolderNameInput'
import { FolderTreeRow } from './folder-tree/FolderTreeRow'
import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
interface FolderTreeProps {
folders: FolderNode[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onCreateFolder?: (name: string) => void
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
collapsed?: boolean
onToggle?: () => void
}
function FolderItem({
node, depth, selection, expanded, onToggle, onSelect,
}: {
node: FolderNode
depth: number
selection: SidebarSelection
expanded: Record<string, boolean>
onToggle: (path: string) => void
onSelect: (selection: SidebarSelection) => void
}) {
const isSelected = selection.kind === 'folder' && selection.path === node.path
const isExpanded = expanded[node.path] ?? false
const hasChildren = node.children.length > 0
export const FolderTree = memo(function FolderTree({
folders,
selection,
onSelect,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
collapsed: externalCollapsed,
onToggle,
}: FolderTreeProps) {
const {
closeCreateForm,
expanded,
handleToggleSection,
isCreating,
openCreateForm,
sectionCollapsed,
toggleFolder,
} = useFolderTreeDisclosure({
collapsed: externalCollapsed,
onToggle,
renamingFolderPath,
selection,
})
const {
closeContextMenu,
contextMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRenameFromMenu,
menuRef,
} = useFolderContextMenu({
onDeleteFolder,
onStartRenameFolder,
})
const handleClick = () => {
onSelect({ kind: 'folder', path: node.path })
if (hasChildren) onToggle(node.path)
}
return (
<>
<button
className={cn(
'flex w-full items-center gap-2 rounded-[5px] border-none bg-transparent cursor-pointer text-left transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ padding: '5px 8px', paddingLeft: 8 + depth * 16, fontSize: 13 }}
onClick={handleClick}
title={node.path}
>
{isSelected || isExpanded ? (
<FolderOpen size={18} weight="fill" className="shrink-0" />
) : (
<Folder size={18} className="shrink-0" />
)}
<span className={cn('truncate', isSelected && 'font-medium')}>{node.name}</span>
</button>
{isExpanded && hasChildren && (
<div className="relative" style={{ paddingLeft: 15 }}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
/>
{node.children.map((child) => (
<FolderItem
key={child.path}
node={child}
depth={depth + 1}
selection={selection}
expanded={expanded}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
)}
</>
)
}
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder, collapsed: externalCollapsed, onToggle }: FolderTreeProps) {
const [internalCollapsed, setInternalCollapsed] = useState(false)
const sectionCollapsed = externalCollapsed ?? internalCollapsed
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [isCreating, setIsCreating] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const toggleFolder = useCallback((path: string) => {
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
}, [])
useEffect(() => {
if (isCreating) inputRef.current?.focus()
}, [isCreating])
const handleCreateFolder = () => {
const name = newFolderName.trim()
if (name && onCreateFolder) {
onCreateFolder(name)
const handleCreateFolderSubmit = useCallback(async (value: string) => {
const nextName = value.trim()
if (!nextName || !onCreateFolder) {
closeCreateForm()
return true
}
setIsCreating(false)
setNewFolderName('')
}
const created = await onCreateFolder(nextName)
if (created) closeCreateForm()
return created
}, [closeCreateForm, onCreateFolder])
if (folders.length === 0 && !isCreating) return null
return (
<div style={{ padding: '0 6px' }}>
{/* Header */}
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={() => onToggle ? onToggle() : setInternalCollapsed((v) => !v)}
>
<div className="flex items-center gap-1">
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FOLDERS</span>
</div>
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="FOLDERS" collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); setIsCreating(true); if (sectionCollapsed && onToggle) onToggle(); else if (sectionCollapsed) setInternalCollapsed(false) }}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
/>
title="Create folder"
aria-label="Create folder"
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
openCreateForm()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)}
</button>
{/* Tree */}
</SidebarGroupHeader>
{!sectionCollapsed && (
<div className="flex flex-col gap-0.5" style={{ padding: '2px 6px 8px 14px' }}>
<div className="flex flex-col gap-0.5 pb-2">
{folders.map((node) => (
<FolderItem
<FolderTreeRow
key={node.path}
node={node}
depth={0}
selection={selection}
expanded={expanded}
onToggle={toggleFolder}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={handleOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
))}
{isCreating && (
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
<Folder size={18} className="shrink-0 text-muted-foreground" />
<input
ref={inputRef}
className="flex-1 border border-border rounded bg-background px-1.5 py-0.5 text-[13px] text-foreground outline-none focus:border-primary"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') }
}}
onBlur={handleCreateFolder}
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel="New folder name"
initialValue=""
placeholder="Folder name"
data-testid="new-folder-input"
submitOnBlur={true}
testId="new-folder-input"
onCancel={closeCreateForm}
onSubmit={handleCreateFolderSubmit}
/>
</div>
)}
</div>
)}
<FolderContextMenu
menu={contextMenu}
menuRef={menuRef}
onDelete={handleDeleteFromMenu}
onRename={handleRenameFromMenu}
/>
</div>
)
})

View File

@@ -32,7 +32,7 @@ interface InspectorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
@@ -71,7 +71,7 @@ function ValidFrontmatterPanels({
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
}) {
return (
<>
@@ -134,7 +134,7 @@ function PrimaryInspectorPanel({
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onCreateMissingType?: (typeName: string) => Promise<void>
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
}) {
if (frontmatterState === 'valid') {
return (
@@ -162,12 +162,10 @@ function PrimaryInspectorPanel({
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
}
export function Inspector({
collapsed,
onToggle,
function InspectorBody({
entry,
content,
entries,
content,
gitHistory,
vaultPath,
onNavigate,
@@ -179,7 +177,7 @@ export function Inspector({
onCreateAndOpenNote,
onInitializeProperties,
onToggleRawEditor,
}: InspectorProps) {
}: Omit<InspectorProps, 'collapsed' | 'onToggle'>) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
@@ -198,40 +196,46 @@ export function Inspector({
onCreateMissingType,
})
if (!entry) {
return <EmptyInspector />
}
return (
<>
<PrimaryInspectorPanel
entry={entry}
frontmatterState={frontmatterState}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onToggleRawEditor={onToggleRawEditor}
onInitializeProperties={onInitializeProperties}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
)
}
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
{entry ? (
<>
<PrimaryInspectorPanel
entry={entry}
frontmatterState={frontmatterState}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}
vaultPath={vaultPath}
referencedBy={referencedBy}
onNavigate={onNavigate}
onToggleRawEditor={onToggleRawEditor}
onInitializeProperties={onInitializeProperties}
onCreateAndOpenNote={onCreateAndOpenNote}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
/>
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (
<EmptyInspector />
)}
<InspectorBody {...bodyProps} />
</div>
)}
</aside>

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
/>,
)
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})
it('renders reconnect and disconnect actions for an already connected vault', () => {
render(
<McpSetupDialog
open={true}
status="installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
/>,
)
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Reconnect External AI Tools')
expect(screen.getByTestId('mcp-setup-disconnect')).toHaveTextContent('Disconnect')
})
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onDisconnect = vi.fn()
render(
<McpSetupDialog
open={true}
status="installed"
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,109 @@
import { ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { McpStatus } from '../hooks/useMcpStatus'
interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
}
function isConnected(status: McpStatus): boolean {
return status === 'installed'
}
function actionCopy(status: McpStatus) {
if (isConnected(status)) {
return {
description: 'Tolaria is already connected to external AI tools for this vault. Reconnect to refresh the configuration, or disconnect to remove Tolaria from those third-party config files.',
primaryLabel: 'Reconnect External AI Tools',
secondaryLabel: 'Disconnect',
title: 'Manage External AI Tools',
}
}
return {
description: 'Tolaria can add its MCP server to external AI tools for this vault, but it will not touch third-party config files until you confirm here.',
primaryLabel: 'Connect External AI Tools',
secondaryLabel: null,
title: 'Set Up External AI Tools',
}
}
export function McpSetupDialog({
open,
status,
busyAction,
onClose,
onConnect,
onDisconnect,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck size={18} />
{copy.title}
</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Confirming this action will write or update Tolaria&apos;s single <code className="rounded bg-muted px-1 py-0.5 text-xs">tolaria</code> MCP entry in:
</p>
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude/mcp.json</div>
<div>~/.cursor/mcp.json</div>
</div>
<p>
The entry points those tools at the current vault. Cancel leaves both files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, memo } from 'react'
import { useCallback, memo } from 'react'
import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types'
import {
KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent,
@@ -6,10 +6,8 @@ import {
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { FolderTree } from './FolderTree'
import {
applyCustomization,
computeReorder,
useEntryCounts,
useOutsideClick,
useSidebarCollapsed,
useSidebarSections,
} from './sidebar/sidebarHooks'
@@ -23,6 +21,7 @@ import {
TypesSection,
ViewsSection,
} from './sidebar/SidebarSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,7 +42,12 @@ interface SidebarProps {
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
showInbox?: boolean
inboxCount?: number
onCollapse?: () => void
@@ -66,40 +70,30 @@ export const Sidebar = memo(function Sidebar({
onDeleteView,
folders = [],
onCreateFolder,
onRenameFolder,
onDeleteFolder,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
onCollapse,
onCreateNewType,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [showCustomize, setShowCustomize] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const { activeCount, archivedCount } = useEntryCounts(entries)
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
const typeInteractions = useSidebarTypeInteractions({
allSectionGroups,
typeEntryMap,
onCustomizeType,
onUpdateTypeTemplate,
onRenameSection,
})
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const closeContextMenu = useCallback(() => {
setContextMenuPos(null)
setContextMenuType(null)
}, [])
const closeCustomize = useCallback(() => setShowCustomize(false), [])
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
useOutsideClick(customizeRef, showCustomize, closeCustomize)
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -112,44 +106,15 @@ export const Sidebar = memo(function Sidebar({
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => {
event.preventDefault()
event.stopPropagation()
setContextMenuPos({ x: event.clientX, y: event.clientY })
setContextMenuType(type)
}, [])
const cancelRename = useCallback(() => setRenamingType(null), [])
const handleStartRename = useCallback((type: string) => {
closeContextMenu()
const group = allSectionGroups.find((sectionGroup) => sectionGroup.type === type)
setRenameInitialValue(group?.label ?? type)
setRenamingType(type)
}, [allSectionGroups, closeContextMenu])
const handleRenameSubmit = useCallback((value: string) => {
if (renamingType) onRenameSection?.(renamingType, value)
setRenamingType(null)
}, [renamingType, onRenameSection])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [customizeTarget, typeEntryMap, onCustomizeType])
const handleChangeTemplate = useCallback((template: string) => {
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
}, [customizeTarget, onUpdateTypeTemplate])
const sectionProps: SidebarSectionProps = {
entries,
selection,
onSelect,
onContextMenu: handleContextMenu,
renamingType,
renameInitialValue,
onRenameSubmit: handleRenameSubmit,
onRenameCancel: cancelRename,
onContextMenu: typeInteractions.handleContextMenu,
renamingType: typeInteractions.renamingType,
renameInitialValue: typeInteractions.renameInitialValue,
onRenameSubmit: typeInteractions.handleRenameSubmit,
onRenameCancel: typeInteractions.cancelRename,
}
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
@@ -202,39 +167,41 @@ export const Sidebar = memo(function Sidebar({
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={showCustomize}
setShowCustomize={setShowCustomize}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={customizeRef}
customizeRef={typeInteractions.customizeRef}
/>
<FolderTree
folders={folders}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
onToggle={() => toggleGroup('folders')}
/>
</nav>
<ContextMenuOverlay
pos={contextMenuPos}
type={contextMenuType}
innerRef={contextMenuRef}
onOpenCustomize={(type) => {
closeContextMenu()
setCustomizeTarget(type)
}}
onStartRename={handleStartRename}
pos={typeInteractions.contextMenuPos}
type={typeInteractions.contextMenuType}
innerRef={typeInteractions.contextMenuRef}
onOpenCustomize={typeInteractions.openCustomizeTarget}
onStartRename={typeInteractions.handleStartRename}
/>
<CustomizeOverlay
target={customizeTarget}
target={typeInteractions.customizeTarget}
typeEntryMap={typeEntryMap}
innerRef={popoverRef}
onCustomize={handleCustomize}
onChangeTemplate={handleChangeTemplate}
onClose={closeCustomizeTarget}
innerRef={typeInteractions.popoverRef}
onCustomize={typeInteractions.handleCustomize}
onChangeTemplate={typeInteractions.handleChangeTemplate}
onClose={typeInteractions.closeCustomizeTarget}
/>
</aside>
)

View File

@@ -316,15 +316,7 @@ describe('StatusBar', () => {
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'MCP server not installed — click to install' }), 'MCP server not installed — click to install')
})
it('shows MCP warning badge when status is no_claude_cli', async () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
await expectTooltip(screen.getByRole('button', { name: 'Claude CLI not found — install it first' }), 'Claude CLI not found — install it first')
await expectTooltip(screen.getByRole('button', { name: 'External AI tools not connected — click to set up' }), 'External AI tools not connected — click to set up')
})
it('hides MCP badge when status is installed', () => {
@@ -357,15 +349,6 @@ describe('StatusBar', () => {
expect(onInstallMcp).toHaveBeenCalledOnce()
})
it('does not call onInstallMcp when clicking MCP badge with no_claude_cli status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows Pull required label when syncStatus is pull_required', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />

View File

@@ -118,7 +118,7 @@ function MissingTypeWarning({
onCreateMissingType,
}: {
missingTypeName: string
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const canCreateMissingType = Boolean(onCreateMissingType)
@@ -148,9 +148,7 @@ function MissingTypeWarning({
<CreateTypeDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onCreate={async (typeName) => {
await onCreateMissingType?.(typeName)
}}
onCreate={(typeName) => onCreateMissingType?.(typeName)}
initialName={missingTypeName}
/>
)}
@@ -165,7 +163,7 @@ function TypeRowValue({
}: {
children: ReactNode
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
return (
<div className="flex min-w-0 items-center justify-start gap-1">
@@ -191,7 +189,7 @@ function ReadOnlyType({
customColorKey?: string | null
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}) {
if (!isA) return null
return (
@@ -230,7 +228,7 @@ interface TypeSelectorProps {
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
missingTypeName?: string | null
onCreateMissingType?: (typeName: string) => void | Promise<void>
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
}
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {

View File

@@ -0,0 +1,54 @@
import type { RefObject } from 'react'
import { PencilSimple, Trash } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
export interface FolderContextMenuState {
path: string
x: number
y: number
}
interface FolderContextMenuProps {
menu: FolderContextMenuState | null
menuRef: RefObject<HTMLDivElement | null>
onDelete?: (folderPath: string) => void
onRename: (folderPath: string) => void
}
export function FolderContextMenu({
menu,
menuRef,
onDelete,
onRename,
}: FolderContextMenuProps) {
if (!menu) return null
return (
<div
ref={menuRef}
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
style={{ left: menu.x, top: menu.y, minWidth: 180 }}
data-testid="folder-context-menu"
>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
Rename folder
</Button>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm text-destructive hover:text-destructive"
onClick={() => onDelete?.(menu.path)}
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
Delete folder
</Button>
</div>
)
}

View File

@@ -0,0 +1,228 @@
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import {
CaretDown,
CaretRight,
Folder,
FolderOpen,
PencilSimple,
Trash,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
interface FolderItemRowProps {
contentInset: number
depthIndent: number
isExpanded: boolean
isSelected: boolean
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
}
export function FolderItemRow({
contentInset,
depthIndent,
isExpanded,
isSelected,
node,
onDeleteFolder,
onOpenMenu,
onSelect,
onStartRenameFolder,
onToggle,
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
onSelect,
onToggle: () => onToggle(node.path),
})
return (
<div
className={cn(
'group relative flex items-center gap-1 rounded transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
onContextMenu={(event) => {
onSelect()
onOpenMenu(node, event)
}}
>
<FolderToggleButton
expandLabel={expandLabel}
hasChildren={hasChildren}
isExpanded={isExpanded}
onToggle={() => onToggle(node.path)}
/>
<FolderSelectButton
contentInset={contentInset}
hasActions={hasActions}
hasChildren={hasChildren}
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onClick={handleSelectClick}
onDoubleClick={handleRenameDoubleClick}
/>
{hasActions && (
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={`Rename ${node.name}`}
testId={`rename-folder-btn:${node.path}`}
title="Rename folder"
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
}}
>
<PencilSimple size={12} />
</FolderActionButton>
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={`Delete ${node.name}`}
testId={`delete-folder-btn:${node.path}`}
title="Delete folder"
destructive
onClick={() => {
onSelect()
onDeleteFolder(node.path)
}}
>
<Trash size={12} />
</FolderActionButton>
)}
</div>
)}
</div>
)
}
function FolderToggleButton({
expandLabel,
hasChildren,
isExpanded,
onToggle,
}: {
expandLabel: string
hasChildren: boolean
isExpanded: boolean
onToggle: () => void
}) {
if (!hasChildren) return null
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
aria-label={expandLabel}
>
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</Button>
)
}
function FolderActionButton({
ariaLabel,
children,
destructive = false,
onClick,
testId,
title,
}: {
ariaLabel: string
children: ReactNode
destructive?: boolean
onClick: () => void
testId: string
title: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
title={title}
className={cn(
'h-5 w-5 rounded p-0 text-muted-foreground',
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
)}
data-testid={testId}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
>
{children}
</Button>
)
}
function FolderSelectButton({
contentInset,
hasActions,
hasChildren,
isExpanded,
isSelected,
node,
onClick,
onDoubleClick,
}: {
contentInset: number
hasActions: boolean
hasChildren: boolean
isExpanded: boolean
isSelected: boolean
node: FolderNode
onClick: (clickDetail: number) => void
onDoubleClick: () => void
}) {
return (
<Button
type="button"
variant="ghost"
className={cn(
'h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium hover:bg-transparent',
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
)}
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: hasChildren ? 0 : contentInset,
paddingRight: hasActions ? 48 : 16,
}}
title={node.path}
onClick={(event) => onClick(event.detail)}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}
>
{isSelected || isExpanded ? (
<FolderOpen size={17} weight="fill" className="size-[17px] shrink-0" />
) : (
<Folder size={17} className="size-[17px] shrink-0" />
)}
<span className="truncate">{node.name}</span>
</Button>
)
}

View File

@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Folder } from '@phosphor-icons/react'
import { Input } from '@/components/ui/input'
interface FolderNameInputProps {
ariaLabel: string
initialValue: string
placeholder: string
leftInset?: number
selectTextOnFocus?: boolean
submitOnBlur?: boolean
testId: string
onCancel: () => void
onSubmit: (value: string) => Promise<boolean> | boolean
}
export function FolderNameInput({
ariaLabel,
initialValue,
placeholder,
leftInset = 16,
selectTextOnFocus = false,
submitOnBlur = false,
testId,
onCancel,
onSubmit,
}: FolderNameInputProps) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
const submittingRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (!input) return
input.focus()
if (selectTextOnFocus) input.select()
}, [selectTextOnFocus])
const handleSubmit = useCallback(async () => {
if (submittingRef.current) return false
submittingRef.current = true
try {
return await onSubmit(value)
} finally {
submittingRef.current = false
}
}, [onSubmit, value])
return (
<div className="flex items-center gap-2 rounded" style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 16, paddingLeft: leftInset, borderRadius: 4 }}>
<Folder size={17} className="size-[17px] shrink-0 text-muted-foreground" />
<Input
ref={inputRef}
aria-label={ariaLabel}
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleSubmit()
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}}
placeholder={placeholder}
data-testid={testId}
/>
</div>
)
}

View File

@@ -0,0 +1,171 @@
import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react'
import type { FolderNode, SidebarSelection } from '../../types'
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { FolderNameInput } from './FolderNameInput'
import { FolderItemRow } from './FolderItemRow'
interface FolderTreeRowProps {
depth: number
expanded: Record<string, boolean>
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onSelect: (selection: SidebarSelection) => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
onCancelRenameFolder?: () => void
renamingFolderPath?: string | null
selection: SidebarSelection
}
function FolderRenameRow({
contentInset,
depthIndent,
node,
onCancelRenameFolder,
onRenameFolder,
}: {
contentInset: number
depthIndent: number
node: FolderNode
onCancelRenameFolder: () => void
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
}) {
return (
<div style={{ paddingLeft: depthIndent }}>
<FolderNameInput
ariaLabel="Folder name"
initialValue={node.name}
placeholder="Folder name"
leftInset={contentInset}
selectTextOnFocus={true}
testId="rename-folder-input"
onCancel={onCancelRenameFolder}
onSubmit={(nextName) => onRenameFolder(node.path, nextName)}
/>
</div>
)
}
function FolderChildren({
depth,
expanded,
node,
onDeleteFolder,
onOpenMenu,
onRenameFolder,
onSelect,
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
const isExpanded = expanded[node.path] ?? false
const hasChildren = node.children.length > 0
if (!isExpanded || !hasChildren) return null
return (
<div className="relative" style={{ paddingLeft: 15 }}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
/>
{node.children.map((child) => (
<FolderTreeRow
key={child.path}
depth={depth + 1}
expanded={expanded}
node={child}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
))}
</div>
)
}
export const FolderTreeRow = memo(function FolderTreeRow({
depth,
expanded,
node,
onDeleteFolder,
onOpenMenu,
onRenameFolder,
onSelect,
onStartRenameFolder,
onToggle,
onCancelRenameFolder,
renamingFolderPath,
selection,
}: FolderTreeRowProps) {
const isExpanded = expanded[node.path] ?? false
const isRenaming = renamingFolderPath === node.path
const isSelected = selection.kind === 'folder' && selection.path === node.path
const depthIndent = depth * 16
const contentInset = 16
const noteRetargeting = useNoteRetargetingContext()
const selectFolder = useCallback(() => {
onSelect({ kind: 'folder', path: node.path })
}, [node.path, onSelect])
const row = (
<FolderItemRow
contentInset={contentInset}
depthIndent={depthIndent}
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
/>
)
return (
<>
{isRenaming && onRenameFolder && onCancelRenameFolder ? (
<FolderRenameRow
contentInset={contentInset}
depthIndent={depthIndent}
node={node}
onCancelRenameFolder={onCancelRenameFolder}
onRenameFolder={onRenameFolder}
/>
) : (
noteRetargeting ? (
<NoteDropTarget
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnFolder(notePath, node.path)}
onDropNote={(notePath) => noteRetargeting.dropNoteOnFolder(notePath, node.path)}
>
{row}
</NoteDropTarget>
) : row
)}
<FolderChildren
depth={depth}
expanded={expanded}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={onToggle}
onCancelRenameFolder={onCancelRenameFolder}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
</>
)
})

View File

@@ -0,0 +1,22 @@
export function expandedTreePaths(path: string): string[] {
const segments = path.split('/').filter(Boolean)
return segments.map((_, index) => segments.slice(0, index + 1).join('/'))
}
export function ancestorTreePaths(path: string): string[] {
return expandedTreePaths(path).slice(0, -1)
}
export function mergeExpandedPaths(
current: Record<string, boolean>,
paths: string[],
): Record<string, boolean> {
let changed = false
const nextExpanded = { ...current }
for (const path of paths) {
if (nextExpanded[path]) continue
nextExpanded[path] = true
changed = true
}
return changed ? nextExpanded : current
}

View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import type { FolderNode } from '../../types'
import type { FolderContextMenuState } from './FolderContextMenu'
interface UseFolderContextMenuInput {
onDeleteFolder?: (folderPath: string) => void
onStartRenameFolder?: (folderPath: string) => void
}
export function useFolderContextMenu({
onDeleteFolder,
onStartRenameFolder,
}: UseFolderContextMenuInput) {
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
useEffect(() => {
if (!contextMenu) return
const handleOutsideClick = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) closeContextMenu()
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeContextMenu()
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleEscape)
}
}, [closeContextMenu, contextMenu])
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setContextMenu({
path: node.path,
x: event.clientX,
y: event.clientY,
})
}, [])
const handleRenameFromMenu = useCallback((folderPath: string) => {
closeContextMenu()
onStartRenameFolder?.(folderPath)
}, [closeContextMenu, onStartRenameFolder])
const handleDeleteFromMenu = useCallback((folderPath: string) => {
closeContextMenu()
onDeleteFolder?.(folderPath)
}, [closeContextMenu, onDeleteFolder])
return {
closeContextMenu,
contextMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRenameFromMenu,
menuRef,
}
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef } from 'react'
export const FOLDER_ROW_SINGLE_CLICK_DELAY_MS = 180
interface UseFolderRowInteractionsInput {
hasChildren: boolean
onRenameFolder?: () => void
onSelect: () => void
onToggle: () => void
}
export function useFolderRowInteractions({
hasChildren,
onRenameFolder,
onSelect,
onToggle,
}: UseFolderRowInteractionsInput) {
const pendingToggleRef = useRef<number | null>(null)
const clearPendingToggle = useCallback(() => {
if (pendingToggleRef.current === null) return
window.clearTimeout(pendingToggleRef.current)
pendingToggleRef.current = null
}, [])
useEffect(() => clearPendingToggle, [clearPendingToggle])
const handleSelectClick = useCallback((clickDetail: number) => {
onSelect()
if (!hasChildren) return
if (clickDetail === 0) {
clearPendingToggle()
onToggle()
return
}
if (clickDetail !== 1) return
clearPendingToggle()
pendingToggleRef.current = window.setTimeout(() => {
pendingToggleRef.current = null
onToggle()
}, FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
}, [clearPendingToggle, hasChildren, onSelect, onToggle])
const handleRenameDoubleClick = useCallback(() => {
clearPendingToggle()
onRenameFolder?.()
}, [clearPendingToggle, onRenameFolder])
return {
handleRenameDoubleClick,
handleSelectClick,
}
}

View File

@@ -0,0 +1,98 @@
import { useCallback, useMemo, useState } from 'react'
import type { SidebarSelection } from '../../types'
import { ancestorTreePaths, expandedTreePaths, mergeExpandedPaths } from './folderTreeUtils'
interface UseFolderTreeDisclosureInput {
collapsed?: boolean
onToggle?: () => void
renamingFolderPath?: string | null
selection: SidebarSelection
}
function useExpandedFolders(selection: SidebarSelection, renamingFolderPath?: string | null) {
const [manualExpanded, setManualExpanded] = useState<Record<string, boolean>>({})
const requiredExpandedPaths = useMemo(() => {
const nextPaths: string[] = []
if (selection.kind === 'folder') nextPaths.push(...ancestorTreePaths(selection.path))
if (renamingFolderPath) nextPaths.push(...expandedTreePaths(renamingFolderPath))
return [...new Set(nextPaths)]
}, [renamingFolderPath, selection])
const expanded = useMemo(
() => mergeExpandedPaths(manualExpanded, requiredExpandedPaths),
[manualExpanded, requiredExpandedPaths],
)
const toggleFolder = useCallback((path: string) => {
setManualExpanded((current) => ({ ...current, [path]: !current[path] }))
}, [])
return {
expanded,
toggleFolder,
}
}
function useFolderSectionState(
externalCollapsed: boolean | undefined,
onToggle: (() => void) | undefined,
renamingFolderPath?: string | null,
) {
const [internalCollapsed, setInternalCollapsed] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const baseSectionCollapsed = externalCollapsed ?? internalCollapsed
const sectionCollapsed = !isCreating && !renamingFolderPath && baseSectionCollapsed
const handleToggleSection = useCallback(() => {
if (onToggle) {
onToggle()
return
}
setInternalCollapsed((current) => !current)
}, [onToggle])
const openCreateForm = useCallback(() => {
if (baseSectionCollapsed) {
if (onToggle) onToggle()
else setInternalCollapsed(false)
}
setIsCreating(true)
}, [baseSectionCollapsed, onToggle])
const closeCreateForm = useCallback(() => setIsCreating(false), [])
return {
handleToggleSection,
isCreating,
openCreateForm,
sectionCollapsed,
closeCreateForm,
}
}
export function useFolderTreeDisclosure({
collapsed: externalCollapsed,
onToggle,
renamingFolderPath,
selection,
}: UseFolderTreeDisclosureInput) {
const { expanded, toggleFolder } = useExpandedFolders(selection, renamingFolderPath)
const {
closeCreateForm,
handleToggleSection,
isCreating,
openCreateForm,
sectionCollapsed,
} = useFolderSectionState(externalCollapsed, onToggle, renamingFolderPath)
return {
closeCreateForm,
expanded,
handleToggleSection,
isCreating,
openCreateForm,
sectionCollapsed,
toggleFolder,
}
}

View File

@@ -8,7 +8,7 @@ interface InspectorPropertyActionsConfig {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
}
function bindEntryAction<TArgs extends unknown[], TResult>(
@@ -21,7 +21,7 @@ function bindEntryAction<TArgs extends unknown[], TResult>(
function bindMissingTypeAction(
entry: VaultEntry | null,
action: ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined,
action: ((path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>) | undefined,
) {
const missingType = entry?.isA
if (!entry || !missingType || !action) return undefined

View File

@@ -11,6 +11,7 @@ import type {
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import { DraggableNoteItem } from '../note-retargeting/DraggableNoteItem'
import { prefetchNoteContent } from '../../hooks/useTabManagement'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
@@ -349,21 +350,39 @@ function useRenderItem({
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
<NoteItem
key={entry.path}
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
isDeletedNoteEntry(entry) ? (
<NoteItem
key={entry.path}
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onContextMenu={contextMenuHandler}
/>
) : (
<DraggableNoteItem key={entry.path} notePath={entry.path}>
<NoteItem
entry={entry}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
changeStatus={getChangeStatus(entry.path)}
typeEntryMap={typeEntryMap}
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
</DraggableNoteItem>
)
), [
contextMenuHandler,
displayPropsOverride,
@@ -581,18 +600,22 @@ export function useNoteListModel({
interaction.noteListKeyboard.focusList()
})
}
const {
isPanelActive: isNoteListSearchActive,
toggleSearchShortcut,
} = interaction.noteListKeyboard
useEffect(() => {
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
dispatchNoteListSearchAvailability(isNoteListSearchActive)
return () => dispatchNoteListSearchAvailability(false)
}, [interaction.noteListKeyboard.isPanelActive])
}, [isNoteListSearchActive])
useEffect(() => {
return addNoteListSearchToggleListener(() => {
if (!interaction.noteListKeyboard.isPanelActive) return
interaction.noteListKeyboard.toggleSearchShortcut()
if (!isNoteListSearchActive) return
toggleSearchShortcut()
})
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
}, [isNoteListSearchActive, toggleSearchShortcut])
return buildNoteListLayoutModel({
selection,

View File

@@ -0,0 +1,29 @@
import type { DragEvent, ReactNode } from 'react'
import { clearDraggedNotePath, writeDraggedNotePath } from './noteDragData'
interface DraggableNoteItemProps {
notePath: string
children: ReactNode
}
export function DraggableNoteItem({ notePath, children }: DraggableNoteItemProps) {
const handleDragStart = (event: DragEvent<HTMLDivElement>) => {
writeDraggedNotePath(event, notePath)
}
const handleDragEnd = () => {
clearDraggedNotePath()
}
return (
<div
draggable
data-testid={`draggable-note:${notePath}`}
className="cursor-grab active:cursor-grabbing"
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DraggableNoteItem } from './DraggableNoteItem'
import { NoteDropTarget } from './NoteDropTarget'
import { clearDraggedNotePath } from './noteDragData'
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
const NOTE_PATH = '/vault/inbox/tolaria-social-media.md'
function createMockDataTransfer(options?: {
readable?: boolean
seedData?: Record<string, string>
}): DataTransfer {
const data = new Map(Object.entries(options?.seedData ?? {}))
const types = Array.from(data.keys())
return {
effectAllowed: 'move',
dropEffect: 'none',
setData(type: string, value: string) {
data.set(type, value)
if (!types.includes(type)) types.push(type)
},
getData(type: string) {
if (options?.readable === false) return ''
return data.get(type) ?? ''
},
clearData(type?: string) {
if (type) {
data.delete(type)
const typeIndex = types.indexOf(type)
if (typeIndex >= 0) types.splice(typeIndex, 1)
return
}
data.clear()
types.splice(0, types.length)
},
get types() {
return types
},
} as DataTransfer
}
function serializedDragData(dataTransfer: DataTransfer) {
return {
[NOTE_DRAG_MIME]: dataTransfer.getData(NOTE_DRAG_MIME),
'text/plain': dataTransfer.getData('text/plain'),
}
}
describe('NoteDropTarget', () => {
afterEach(() => {
clearDraggedNotePath()
})
it('keeps a note drag valid when dragover cannot read DataTransfer payloads', async () => {
const onDropNote = vi.fn()
const canAcceptNotePath = vi.fn((notePath: string) => notePath === NOTE_PATH)
render(
<>
<DraggableNoteItem notePath={NOTE_PATH}>
<span>Drag Tolaria Social media</span>
</DraggableNoteItem>
<NoteDropTarget canAcceptNotePath={canAcceptNotePath} onDropNote={onDropNote}>
<span>CircleCI Series</span>
</NoteDropTarget>
</>,
)
const dragStartData = createMockDataTransfer()
fireEvent.dragStart(screen.getByTestId(`draggable-note:${NOTE_PATH}`), { dataTransfer: dragStartData })
const target = screen.getByText('CircleCI Series').parentElement
expect(target).not.toBeNull()
const lockedDragData = createMockDataTransfer({
readable: false,
seedData: serializedDragData(dragStartData),
})
fireEvent.dragEnter(target as HTMLElement, { dataTransfer: lockedDragData })
fireEvent.dragOver(target as HTMLElement, { dataTransfer: lockedDragData })
expect(canAcceptNotePath).toHaveBeenCalledWith(NOTE_PATH)
expect(target).toHaveAttribute('data-drop-state', 'valid')
const dropData = createMockDataTransfer({
seedData: serializedDragData(dragStartData),
})
fireEvent.drop(target as HTMLElement, { dataTransfer: dropData })
await waitFor(() => {
expect(onDropNote).toHaveBeenCalledWith(NOTE_PATH)
})
expect(target).not.toHaveAttribute('data-drop-state')
})
})

View File

@@ -0,0 +1,92 @@
import { useRef, useState, type DragEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { clearDraggedNotePath, readDraggedNotePath } from './noteDragData'
type DropState = 'idle' | 'valid' | 'invalid'
interface NoteDropTargetProps {
children: ReactNode
className?: string
validClassName?: string
invalidClassName?: string
canAcceptNotePath: (notePath: string) => boolean
onDropNote: (notePath: string) => void | Promise<void>
}
export function NoteDropTarget({
children,
className,
validClassName = 'ring-1 ring-primary/40 bg-primary/10',
invalidClassName = 'ring-1 ring-destructive/35 bg-destructive/5',
canAcceptNotePath,
onDropNote,
}: NoteDropTargetProps) {
const [dropState, setDropState] = useState<DropState>('idle')
const dragDepthRef = useRef(0)
const updateDropState = (event: DragEvent<HTMLDivElement>): string | null => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return null
const isValid = canAcceptNotePath(notePath)
event.preventDefault()
event.dataTransfer.dropEffect = isValid ? 'move' : 'none'
setDropState(isValid ? 'valid' : 'invalid')
return notePath
}
const resetDropState = () => {
dragDepthRef.current = 0
setDropState('idle')
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return
dragDepthRef.current += 1
updateDropState(event)
}
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
updateDropState(event)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
const notePath = readDraggedNotePath(event.dataTransfer)
if (!notePath) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0 && !event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropState('idle')
}
}
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
const notePath = updateDropState(event)
if (!notePath) return
const isValid = canAcceptNotePath(notePath)
resetDropState()
clearDraggedNotePath()
if (!isValid) return
void onDropNote(notePath)
}
return (
<div
className={cn(
'rounded-[5px] transition-colors',
className,
dropState === 'valid' && validClassName,
dropState === 'invalid' && invalidClassName,
)}
data-drop-state={dropState === 'idle' ? undefined : dropState}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import {
noteRetargetingContext,
type NoteRetargetingContextValue,
} from './noteRetargetingContext'
export function NoteRetargetingProvider({
children,
value,
}: {
children: ReactNode
value: NoteRetargetingContextValue | null
}) {
return (
<noteRetargetingContext.Provider value={value}>
{children}
</noteRetargetingContext.Provider>
)
}

View File

@@ -0,0 +1,62 @@
import type { VaultEntry } from '../../types'
import type { RetargetOption } from './RetargetNoteDialog'
import { RetargetNoteDialog } from './RetargetNoteDialog'
interface NoteRetargetingDialogsProps {
dialogState: { kind: 'type' | 'folder'; notePath: string } | null
dialogEntry: VaultEntry | null
typeOptions: RetargetOption[]
folderOptions: RetargetOption[]
onClose: () => void
onSelectType: (type: string) => boolean | Promise<boolean>
onSelectFolder: (folderPath: string) => boolean | Promise<boolean>
}
function typeDialogDescription(entry: VaultEntry | null): string {
return entry
? `Set a new type for "${entry.title}".`
: 'Select a type for the active note.'
}
function folderDialogDescription(entry: VaultEntry | null): string {
return entry
? `Choose a destination folder for "${entry.title}".`
: 'Select a destination folder for the active note.'
}
export function NoteRetargetingDialogs({
dialogState,
dialogEntry,
typeOptions,
folderOptions,
onClose,
onSelectType,
onSelectFolder,
}: NoteRetargetingDialogsProps) {
return (
<>
<RetargetNoteDialog
open={dialogState?.kind === 'type'}
title="Change Note Type"
description={typeDialogDescription(dialogEntry)}
searchPlaceholder="Search types"
emptyMessage="No other note types available."
options={typeOptions}
onClose={onClose}
onSelect={onSelectType}
testIdPrefix="retarget-note-type"
/>
<RetargetNoteDialog
open={dialogState?.kind === 'folder'}
title="Move Note to Folder"
description={folderDialogDescription(dialogEntry)}
searchPlaceholder="Search folders"
emptyMessage="No other folders available."
options={folderOptions}
onClose={onClose}
onSelect={onSelectFolder}
testIdPrefix="retarget-note-folder"
/>
</>
)
}

View File

@@ -0,0 +1,174 @@
import { Check, StackSimple } from '@phosphor-icons/react'
import { useMemo, useState, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
export interface RetargetOption {
id: string
label: string
detail?: string
current?: boolean
}
interface RetargetNoteDialogProps {
open: boolean
title: string
description: string
searchPlaceholder: string
emptyMessage: string
options: RetargetOption[]
onClose: () => void
onSelect: (id: string) => boolean | Promise<boolean>
testIdPrefix: string
}
function matchesQuery(option: RetargetOption, query: string): boolean {
const normalized = query.trim().toLowerCase()
if (!normalized) return true
return option.label.toLowerCase().includes(normalized)
|| option.detail?.toLowerCase().includes(normalized)
|| false
}
function initialHighlightIndex(options: RetargetOption[]): number {
if (options.length === 0) return -1
const currentIndex = options.findIndex((option) => option.current)
return currentIndex >= 0 ? currentIndex : 0
}
function nextHighlightIndex(current: number, total: number, direction: 'next' | 'previous'): number {
if (total === 0) return -1
if (current < 0) return direction === 'next' ? 0 : total - 1
return direction === 'next'
? (current + 1) % total
: (current - 1 + total) % total
}
export function RetargetNoteDialog({
open,
title,
description,
searchPlaceholder,
emptyMessage,
options,
onClose,
onSelect,
testIdPrefix,
}: RetargetNoteDialogProps) {
const [query, setQuery] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const filteredOptions = useMemo(
() => options.filter((option) => matchesQuery(option, query)),
[options, query],
)
const effectiveHighlightedIndex = highlightedIndex >= 0 && highlightedIndex < filteredOptions.length
? highlightedIndex
: initialHighlightIndex(filteredOptions)
const resetDialogState = () => {
setQuery('')
setHighlightedIndex(-1)
}
const submitSelection = async (optionId: string) => {
const shouldClose = await onSelect(optionId)
if (!shouldClose) return
resetDialogState()
onClose()
}
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'next'))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'previous'))
return
}
if (event.key === 'Enter' && effectiveHighlightedIndex >= 0) {
event.preventDefault()
void submitSelection(filteredOptions[effectiveHighlightedIndex].id)
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) return
resetDialogState()
onClose()
}}
>
<DialogContent className="max-w-xl gap-3" showCloseButton={true}>
<DialogHeader className="gap-1">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
data-testid={`${testIdPrefix}-search`}
/>
<ScrollArea className="max-h-80 rounded-md border">
{filteredOptions.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground" data-testid={`${testIdPrefix}-empty`}>
{emptyMessage}
</div>
) : (
<div className="p-1" data-testid={`${testIdPrefix}-options`}>
{filteredOptions.map((option, index) => (
<Button
key={option.id}
type="button"
variant="ghost"
className={cn(
'h-auto w-full justify-start rounded-md px-3 py-2 text-left',
effectiveHighlightedIndex === index && 'bg-accent text-accent-foreground',
)}
data-testid={`${testIdPrefix}-option:${option.id}`}
onMouseMove={() => setHighlightedIndex(index)}
onClick={() => { void submitSelection(option.id) }}
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="mt-0.5 shrink-0 text-muted-foreground">
{option.current ? <Check size={14} weight="bold" /> : <StackSimple size={14} />}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">{option.label}</span>
{option.detail && (
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
)}
</span>
</div>
{option.current && (
<span className="shrink-0 text-xs font-medium text-muted-foreground">
Current
</span>
)}
</Button>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,27 @@
import type { DragEvent } from 'react'
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
let activeDraggedNotePath: string | null = null
export function writeDraggedNotePath(event: DragEvent<HTMLElement>, notePath: string): void {
activeDraggedNotePath = notePath
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData(NOTE_DRAG_MIME, notePath)
event.dataTransfer.setData('text/plain', notePath)
}
export function clearDraggedNotePath(): void {
activeDraggedNotePath = null
}
export function readDraggedNotePath(dataTransfer: DataTransfer | null): string | null {
if (!dataTransfer) return activeDraggedNotePath
const customPath = dataTransfer.getData(NOTE_DRAG_MIME).trim()
if (customPath) return customPath
const fallbackPath = dataTransfer.getData('text/plain').trim()
if (fallbackPath) return fallbackPath
return activeDraggedNotePath
}

View File

@@ -0,0 +1,24 @@
import { createContext, createElement, useContext, type ReactNode } from 'react'
export interface NoteRetargetingContextValue {
canDropNoteOnType: (notePath: string, type: string) => boolean
dropNoteOnType: (notePath: string, type: string) => Promise<void>
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean
dropNoteOnFolder: (notePath: string, folderPath: string) => Promise<void>
}
export const noteRetargetingContext = createContext<NoteRetargetingContextValue | null>(null)
export function useNoteRetargetingContext() {
return useContext(noteRetargetingContext)
}
export function NoteRetargetingProvider({
children,
value,
}: {
children: ReactNode
value: NoteRetargetingContextValue | null
}) {
return createElement(noteRetargetingContext.Provider, { value }, children)
}

View File

@@ -19,6 +19,8 @@ import {
} from '../SidebarParts'
import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
@@ -95,9 +97,24 @@ function SortableSection({
group: SectionGroup
sectionProps: SidebarSectionProps
}) {
const noteRetargeting = useNoteRetargetingContext()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = countByFilter(sectionProps.entries, group.type).open
const isRenaming = sectionProps.renamingType === group.type
const content = (
<SectionContent
group={group}
itemCount={itemCount}
selection={sectionProps.selection}
onSelect={sectionProps.onSelect}
onContextMenu={sectionProps.onContextMenu}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
)
return (
<div
@@ -110,18 +127,14 @@ function SortableSection({
}}
{...attributes}
>
<SectionContent
group={group}
itemCount={itemCount}
selection={sectionProps.selection}
onSelect={sectionProps.onSelect}
onContextMenu={sectionProps.onContextMenu}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
{noteRetargeting ? (
<NoteDropTarget
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnType(notePath, group.type)}
onDropNote={(notePath) => noteRetargeting.dropNoteOnType(notePath, group.type)}
>
{content}
</NoteDropTarget>
) : content}
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useCallback, useRef, useState } from 'react'
import type { VaultEntry } from '../../types'
import { applyCustomization, useOutsideClick } from './sidebarHooks'
interface SidebarTypeGroup {
type: string
label: string
}
interface SidebarTypeInteractionsInput {
allSectionGroups: SidebarTypeGroup[]
typeEntryMap: Record<string, VaultEntry>
onCustomizeType?: (typeName: string, icon: string, color: string) => void
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onRenameSection?: (typeName: string, label: string) => void
}
function useSidebarTypeState() {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [showCustomize, setShowCustomize] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => {
setContextMenuPos(null)
setContextMenuType(null)
}, [])
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
const closeCustomize = useCallback(() => setShowCustomize(false), [])
const cancelRename = useCallback(() => setRenamingType(null), [])
useOutsideClick(customizeRef, showCustomize, closeCustomize)
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
return {
cancelRename,
closeContextMenu,
closeCustomizeTarget,
contextMenuPos,
contextMenuRef,
contextMenuType,
customizeRef,
customizeTarget,
popoverRef,
renameInitialValue,
renamingType,
setContextMenuPos,
setContextMenuType,
setCustomizeTarget,
setRenameInitialValue,
setRenamingType,
setShowCustomize,
showCustomize,
}
}
function useSidebarRenameCallbacks(params: {
allSectionGroups: SidebarTypeGroup[]
closeContextMenu: () => void
onRenameSection?: (typeName: string, label: string) => void
renamingType: string | null
setRenameInitialValue: React.Dispatch<React.SetStateAction<string>>
setRenamingType: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
allSectionGroups,
closeContextMenu,
onRenameSection,
renamingType,
setRenameInitialValue,
setRenamingType,
} = params
const handleStartRename = useCallback((type: string) => {
closeContextMenu()
const group = allSectionGroups.find((sectionGroup) => sectionGroup.type === type)
setRenameInitialValue(group?.label ?? type)
setRenamingType(type)
}, [allSectionGroups, closeContextMenu, setRenameInitialValue, setRenamingType])
const handleRenameSubmit = useCallback((value: string) => {
if (renamingType) onRenameSection?.(renamingType, value)
setRenamingType(null)
}, [onRenameSection, renamingType, setRenamingType])
return { handleRenameSubmit, handleStartRename }
}
export function useSidebarTypeInteractions({
allSectionGroups,
typeEntryMap,
onCustomizeType,
onUpdateTypeTemplate,
onRenameSection,
}: SidebarTypeInteractionsInput) {
const state = useSidebarTypeState()
const renameCallbacks = useSidebarRenameCallbacks({
allSectionGroups,
closeContextMenu: state.closeContextMenu,
onRenameSection,
renamingType: state.renamingType,
setRenameInitialValue: state.setRenameInitialValue,
setRenamingType: state.setRenamingType,
})
const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => {
event.preventDefault()
event.stopPropagation()
state.setContextMenuPos({ x: event.clientX, y: event.clientY })
state.setContextMenuType(type)
}, [state])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
applyCustomization(state.customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [onCustomizeType, state.customizeTarget, typeEntryMap])
const handleChangeTemplate = useCallback((template: string) => {
if (state.customizeTarget) onUpdateTypeTemplate?.(state.customizeTarget, template)
}, [onUpdateTypeTemplate, state.customizeTarget])
const openCustomizeTarget = useCallback((type: string) => {
state.closeContextMenu()
state.setCustomizeTarget(type)
}, [state])
return {
closeCustomizeTarget: state.closeCustomizeTarget,
contextMenuPos: state.contextMenuPos,
contextMenuRef: state.contextMenuRef,
contextMenuType: state.contextMenuType,
customizeRef: state.customizeRef,
customizeTarget: state.customizeTarget,
handleChangeTemplate,
handleContextMenu,
handleCustomize,
handleRenameSubmit: renameCallbacks.handleRenameSubmit,
handleStartRename: renameCallbacks.handleStartRename,
openCustomizeTarget,
popoverRef: state.popoverRef,
cancelRename: state.cancelRename,
renameInitialValue: state.renameInitialValue,
renamingType: state.renamingType,
setShowCustomize: state.setShowCustomize,
showCustomize: state.showCustomize,
}
}

View File

@@ -40,8 +40,7 @@ const SYNC_COLORS: Record<string, string> = {
}
const MCP_TOOLTIPS: Partial<Record<McpStatus, string>> = {
not_installed: 'MCP server not installed — click to install',
no_claude_cli: 'Claude CLI not found — install it first',
not_installed: 'External AI tools not connected — click to set up',
}
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'

View File

@@ -121,6 +121,9 @@ function createMockEditor(blockType = 'image') {
props: { textAlignment: 'center', level: 1 },
content: [{ type: 'text', text: 'Selected block' }],
}
const domElement = document.createElement('div')
domElement.appendChild(document.createElement('div'))
document.body.appendChild(domElement)
return {
isEditable: true,
@@ -133,7 +136,7 @@ function createMockEditor(blockType = 'image') {
},
},
prosemirrorState: { selection: { from: 1, to: 5 } },
domElement: document.createElement('div'),
domElement,
focus: vi.fn(),
getActiveStyles: () => ({ bold: true }),
getSelection: () => ({ blocks: [selectedBlock] }),
@@ -147,6 +150,7 @@ function createMockEditor(blockType = 'image') {
describe('tolariaEditorFormatting behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
positionPopoverState.lastProps = null
showState.value = true
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
@@ -300,4 +304,19 @@ describe('tolariaEditorFormatting behavior', () => {
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
const editor = createMockEditor()
editor.domElement = document.createElement('div')
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({
open: false,
}),
}))
})
})

View File

@@ -282,6 +282,13 @@ function getFormattingToolbarBridgeBlockId(
: null
}
function getFormattingToolbarAnchorElement(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const anchor = editor.domElement?.firstElementChild
return anchor instanceof Element && anchor.isConnected ? anchor : null
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
@@ -470,6 +477,8 @@ export function TolariaFormattingToolbarController(props: {
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null
const shouldRenderFloatingToolbar = isOpen && hasFloatingToolbarAnchor
const currentBridgeBlockId = useEditorState({
editor,
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
@@ -488,7 +497,7 @@ export function TolariaFormattingToolbarController(props: {
const position = useEditorState({
editor,
selector: ({ editor }) => (
isOpen
shouldRenderFloatingToolbar
? {
from: editor.prosemirrorState.selection.from,
to: editor.prosemirrorState.selection.to,
@@ -516,7 +525,7 @@ export function TolariaFormattingToolbarController(props: {
() => ({
...props.floatingUIOptions,
useFloatingOptions: {
open: isOpen,
open: shouldRenderFloatingToolbar,
onOpenChange: (open, _event, reason) => {
formattingToolbar.store.setState(open)
if (!open) {
@@ -542,9 +551,9 @@ export function TolariaFormattingToolbarController(props: {
clearCloseGrace,
editor,
formattingToolbar.store,
isOpen,
placement,
props.floatingUIOptions,
shouldRenderFloatingToolbar,
],
)
@@ -552,7 +561,7 @@ export function TolariaFormattingToolbarController(props: {
return (
<PositionPopover position={position} {...floatingUIOptions}>
{isOpen && (
{shouldRenderFloatingToolbar && (
<div
onPointerEnter={() => {
setToolbarHovered(true)

View File

@@ -7,6 +7,7 @@ import {
findShortcutCommandIdForEvent,
isAppCommandId,
isNativeMenuCommandId,
recordSuppressedShortcutCommand,
resetAppCommandDispatchStateForTests,
type AppCommandHandlers,
} from './appCommandDispatcher'
@@ -280,4 +281,13 @@ describe('appCommandDispatcher', () => {
expect(executeAppCommand(APP_COMMAND_IDS.viewToggleAiChat, handlers, 'renderer-keyboard')).toBe(false)
expect(handlers.onToggleAIChat).toHaveBeenCalledTimes(1)
})
it('suppresses a native-menu history echo after renderer keyboard yields to text editing', () => {
const handlers = makeHandlers()
recordSuppressedShortcutCommand(APP_COMMAND_IDS.viewGoBack)
expect(executeAppCommand(APP_COMMAND_IDS.viewGoBack, handlers, 'native-menu')).toBe(false)
expect(handlers.onGoBack).not.toHaveBeenCalled()
})
})

View File

@@ -25,6 +25,8 @@ export type AppCommandDispatchSource =
| 'native-menu'
| 'app-event'
type SuppressedShortcutSource = Extract<AppCommandDispatchSource, 'renderer-keyboard'>
export interface AppCommandHandlers {
onSetViewMode: (mode: ViewMode) => void
onCreateNote: () => void
@@ -154,6 +156,14 @@ let lastCommandDispatch:
}
| null = null
let lastSuppressedShortcutCommand:
| {
id: AppCommandId
source: SuppressedShortcutSource
timestamp: number
}
| null = null
function now(): number {
return globalThis.performance?.now?.() ?? Date.now()
}
@@ -175,6 +185,16 @@ function shouldSuppressDuplicateCommand(
return currentTimestamp - lastCommandDispatch.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
}
function shouldSuppressShortcutEchoAfterKeyboardYield(
id: AppCommandId,
source: AppCommandDispatchSource,
currentTimestamp: number,
): boolean {
if (source !== 'native-menu') return false
if (!lastSuppressedShortcutCommand || lastSuppressedShortcutCommand.id !== id) return false
return currentTimestamp - lastSuppressedShortcutCommand.timestamp <= SHORTCUT_ECHO_DEDUPE_WINDOW_MS
}
function dispatchActiveTabCommand(
pathRef: MutableRefObject<string | null>,
handler: (path: string) => void,
@@ -249,6 +269,9 @@ export function executeAppCommand(
source: AppCommandDispatchSource,
): boolean {
const timestamp = now()
if (shouldSuppressShortcutEchoAfterKeyboardYield(id, source, timestamp)) {
return false
}
if (shouldSuppressDuplicateCommand(id, source, timestamp)) {
return false
}
@@ -260,6 +283,14 @@ export function executeAppCommand(
return dispatched
}
export function recordSuppressedShortcutCommand(
id: AppCommandId,
source: SuppressedShortcutSource = 'renderer-keyboard',
): void {
lastSuppressedShortcutCommand = { id, source, timestamp: now() }
}
export function resetAppCommandDispatchStateForTests(): void {
lastCommandDispatch = null
lastSuppressedShortcutCommand = null
}

View File

@@ -3,6 +3,8 @@ import {
APP_COMMAND_IDS,
executeAppCommand,
findShortcutCommandIdForEvent,
recordSuppressedShortcutCommand,
type AppCommandId,
type AppCommandHandlers,
} from './appCommandDispatcher'
@@ -33,6 +35,10 @@ export type KeyboardActions = Pick<
>
const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete'])
const TEXT_EDITING_BLOCKED_COMMANDS = new Set<AppCommandId>([
APP_COMMAND_IDS.viewGoBack,
APP_COMMAND_IDS.viewGoForward,
])
function isTextInputFocused(): boolean {
const active = document.activeElement
@@ -44,7 +50,15 @@ function isTextInputFocused(): boolean {
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
const commandId = findShortcutCommandIdForEvent(event)
if (commandId === null) return
if (TEXT_EDITING_KEYS.has(event.key) && isTextInputFocused()) return
const textInputFocused = isTextInputFocused()
if (textInputFocused) {
if (TEXT_EDITING_KEYS.has(event.key)) return
if (TEXT_EDITING_BLOCKED_COMMANDS.has(commandId)) {
recordSuppressedShortcutCommand(commandId, 'renderer-keyboard')
return
}
}
event.preventDefault()
if (commandId === APP_COMMAND_IDS.editFindInVault) {

View File

@@ -4,6 +4,9 @@ import type { SidebarSelection } from '../../types'
interface NavigationCommandsConfig {
onQuickOpen: () => void
onSelect: (sel: SidebarSelection) => void
selection?: SidebarSelection
onRenameFolder?: () => void
onDeleteFolder?: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
@@ -11,9 +14,42 @@ interface NavigationCommandsConfig {
canGoForward?: boolean
}
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
const { onQuickOpen, onSelect, showInbox = true, onGoBack, onGoForward, canGoBack, canGoForward } = config
const commands: CommandAction[] = [
function buildFolderCommands(
folderSelected: boolean,
onRenameFolder?: () => void,
onDeleteFolder?: () => void,
): CommandAction[] {
return [
{
id: 'rename-folder',
label: 'Rename Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'rename'],
enabled: folderSelected && !!onRenameFolder,
execute: () => onRenameFolder?.(),
},
{
id: 'delete-folder',
label: 'Delete Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'delete', 'remove'],
enabled: folderSelected && !!onDeleteFolder,
execute: () => onDeleteFolder?.(),
},
]
}
function buildBaseCommands(config: NavigationCommandsConfig): CommandAction[] {
const {
onQuickOpen,
onSelect,
onGoBack,
onGoForward,
canGoBack,
canGoForward,
} = config
return [
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
@@ -22,15 +58,34 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘←', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘→', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
]
if (showInbox) {
commands.splice(5, 0, {
id: 'go-inbox',
label: 'Go to Inbox',
group: 'Navigation',
keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'],
enabled: true,
execute: () => onSelect({ kind: 'filter', filter: 'inbox' }),
})
}
}
function insertInboxCommand(commands: CommandAction[], showInbox: boolean, onSelect: (sel: SidebarSelection) => void) {
if (!showInbox) return commands
commands.splice(5, 0, {
id: 'go-inbox',
label: 'Go to Inbox',
group: 'Navigation',
keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'],
enabled: true,
execute: () => onSelect({ kind: 'filter', filter: 'inbox' }),
})
return commands
}
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
const {
onSelect,
selection,
onRenameFolder,
onDeleteFolder,
showInbox = true,
} = config
const folderSelected = selection?.kind === 'folder'
const commands = [
...buildBaseCommands(config),
...buildFolderCommands(folderSelected, onRenameFolder, onDeleteFolder),
]
return insertInboxCommand(commands, showInbox, onSelect)
}

View File

@@ -11,6 +11,9 @@ interface NoteCommandsConfig {
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
@@ -22,17 +25,18 @@ interface NoteCommandsConfig {
canRestoreDeletedNote?: boolean
}
interface ActivePathCommandConfig {
interface NoteCommandConfig {
id: string
label: string
keywords: string[]
enabled: boolean
path: string | null
execute?: () => void
shortcut?: string
run: (path: string) => void
path?: string | null
run?: (path: string) => void
}
function createActivePathCommand(config: ActivePathCommandConfig): CommandAction {
function createNoteCommand(config: NoteCommandConfig): CommandAction {
return {
id: config.id,
label: config.label,
@@ -41,84 +45,165 @@ function createActivePathCommand(config: ActivePathCommandConfig): CommandAction
keywords: config.keywords,
enabled: config.enabled,
execute: () => {
if (config.path) config.run(config.path)
if (config.path && config.run) {
config.run(config.path)
return
}
config.execute?.()
},
}
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized,
onRestoreDeletedNote, canRestoreDeletedNote,
} = config
function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
createActivePathCommand({
createNoteCommand({
id: 'create-note',
label: 'New Note',
shortcut: '⌘N',
keywords: ['new', 'create', 'add'],
enabled: true,
execute: config.onCreateNote,
}),
createNoteCommand({
id: 'create-type',
label: 'New Type',
keywords: ['new', 'create', 'type', 'template'],
enabled: !!config.onCreateType,
execute: () => config.onCreateType?.(),
}),
createNoteCommand({
id: 'save-note',
label: 'Save Note',
shortcut: '⌘S',
keywords: ['write'],
enabled: config.hasActiveNote,
execute: config.onSave,
}),
]
}
function buildPathNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildDestructiveNoteCommands(config),
...buildPinnedNoteCommands(config),
]
}
function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'delete-note',
label: 'Delete Note',
shortcut: '⌘⌫',
keywords: ['delete', 'remove'],
enabled: hasActiveNote,
path: activeTabPath,
run: onDeleteNote,
enabled: config.hasActiveNote,
path: config.activeTabPath,
run: config.onDeleteNote,
}),
createActivePathCommand({
createNoteCommand({
id: 'archive-note',
label: isArchived ? 'Unarchive Note' : 'Archive Note',
label: config.isArchived ? 'Unarchive Note' : 'Archive Note',
keywords: ['archive'],
enabled: hasActiveNote,
path: activeTabPath,
run: isArchived ? onUnarchiveNote : onArchiveNote,
enabled: config.hasActiveNote,
path: config.activeTabPath,
run: config.isArchived ? config.onUnarchiveNote : config.onArchiveNote,
}),
{
id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote,
execute: () => onRestoreDeletedNote?.(),
},
createActivePathCommand({
id: 'toggle-favorite',
label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: hasActiveNote && !!onToggleFavorite,
path: activeTabPath,
run: (path) => onToggleFavorite?.(path),
}),
createActivePathCommand({
id: 'toggle-organized',
label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: hasActiveNote && !!onToggleOrganized,
path: activeTabPath,
run: (path) => onToggleOrganized?.(path),
}),
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: hasActiveNote && !!onSetNoteIcon,
execute: () => onSetNoteIcon?.(),
},
{
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
execute: () => onRemoveNoteIcon?.(),
},
{
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: hasActiveNote,
execute: () => onOpenInNewWindow?.(),
},
]
}
function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'toggle-favorite',
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: config.hasActiveNote && !!config.onToggleFavorite,
path: config.activeTabPath,
run: (path) => config.onToggleFavorite?.(path),
}),
createNoteCommand({
id: 'toggle-organized',
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: config.hasActiveNote && !!config.onToggleOrganized,
path: config.activeTabPath,
run: (path) => config.onToggleOrganized?.(path),
}),
]
}
function buildOptionalNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildRecoveryCommands(config),
...buildRetargetingCommands(config),
...buildPresentationCommands(config),
]
}
function buildRecoveryCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'restore-deleted-note',
label: 'Restore Deleted Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!config.canRestoreDeletedNote && !!config.onRestoreDeletedNote,
execute: () => config.onRestoreDeletedNote?.(),
}),
]
}
function buildRetargetingCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'set-note-icon',
label: 'Set Note Icon',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: config.hasActiveNote && !!config.onSetNoteIcon,
execute: () => config.onSetNoteIcon?.(),
}),
createNoteCommand({
id: 'change-note-type',
label: 'Change Note Type…',
keywords: ['type', 'change', 'retarget', 'section', 'move'],
enabled: config.hasActiveNote && !!config.onChangeNoteType,
execute: () => config.onChangeNoteType?.(),
}),
createNoteCommand({
id: 'move-note-to-folder',
label: 'Move Note to Folder…',
keywords: ['folder', 'move', 'retarget', 'organize'],
enabled: config.hasActiveNote && !!config.onMoveNoteToFolder && !!config.canMoveNoteToFolder,
execute: () => config.onMoveNoteToFolder?.(),
}),
]
}
function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
id: 'remove-note-icon',
label: 'Remove Note Icon',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: config.hasActiveNote && !!config.activeNoteHasIcon && !!config.onRemoveNoteIcon,
execute: () => config.onRemoveNoteIcon?.(),
}),
createNoteCommand({
id: 'open-in-new-window',
label: 'Open in New Window',
shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: config.hasActiveNote,
execute: () => config.onOpenInNewWindow?.(),
}),
]
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildCoreNoteCommands(config),
...buildPathNoteCommands(config),
...buildOptionalNoteCommands(config),
]
}

View File

@@ -59,7 +59,14 @@ function buildMaintenanceCommands({
onRepairVault,
}: Pick<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'>): CommandAction[] {
return [
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
]

View File

@@ -0,0 +1,204 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../../mock-tauri'
import type { SidebarSelection, VaultEntry } from '../../types'
export interface FolderRenameResult {
old_path: string
new_path: string
}
export interface FolderTab {
entry: VaultEntry
content: string
}
export interface ConfirmFolderDeleteState {
path: string
title: string
message: string
confirmLabel: string
}
export const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function trimTrailingSlash(path: string): string {
return path.endsWith('/') ? path.slice(0, -1) : path
}
export function folderAbsolutePath(params: { vaultPath: string; folderPath: string }): string {
const normalizedVaultPath = trimTrailingSlash(params.vaultPath)
const normalizedFolderPath = params.folderPath.replace(/^\/+/, '')
return normalizedFolderPath ? `${normalizedVaultPath}/${normalizedFolderPath}` : normalizedVaultPath
}
export function isWithinPrefix(params: { path: string; prefix: string }): boolean {
return params.path === params.prefix || params.path.startsWith(`${params.prefix}/`)
}
export function replaceFolderPrefix(params: {
path: string
oldPrefix: string
newPrefix: string
}): string {
if (!isWithinPrefix({ path: params.path, prefix: params.oldPrefix })) return params.path
return `${params.newPrefix}${params.path.slice(params.oldPrefix.length)}`
}
export function replaceRelativeFolderPrefix(params: {
path: string
oldPrefix: string
newPrefix: string
}): string {
if (!isWithinPrefix({ path: params.path, prefix: params.oldPrefix })) return params.path
return `${params.newPrefix}${params.path.slice(params.oldPrefix.length)}`
}
export function folderLabel(params: { folderPath: string }): string {
return params.folderPath.split('/').filter(Boolean).at(-1) ?? params.folderPath
}
export async function invokeRenameFolder(params: {
vaultPath: string
folderPath: string
newName: string
}): Promise<FolderRenameResult> {
if (isTauri()) {
return invoke<FolderRenameResult>('rename_vault_folder', params)
}
return mockInvoke<FolderRenameResult>('rename_vault_folder', params)
}
export async function invokeDeleteFolder(params: { vaultPath: string; folderPath: string }): Promise<string> {
if (isTauri()) {
return invoke<string>('delete_vault_folder', params)
}
return mockInvoke<string>('delete_vault_folder', params)
}
export function updateSelectionAfterFolderRename(params: {
refreshedEntries: VaultEntry[]
renameResult: FolderRenameResult
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
vaultPath: string
}) {
const {
refreshedEntries,
renameResult,
selection,
setSelection,
vaultPath,
} = params
if (selection.kind === 'folder') {
if (!isWithinPrefix({ path: selection.path, prefix: renameResult.old_path })) return
setSelection({
kind: 'folder',
path: replaceRelativeFolderPrefix({
path: selection.path,
oldPrefix: renameResult.old_path,
newPrefix: renameResult.new_path,
}),
})
return
}
if (selection.kind !== 'entity') return
const oldPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.old_path })
if (!isWithinPrefix({ path: selection.entry.path, prefix: oldPrefix })) return
const renamedPath = replaceFolderPrefix({
path: selection.entry.path,
oldPrefix,
newPrefix: folderAbsolutePath({ vaultPath, folderPath: renameResult.new_path }),
})
const refreshedEntry = refreshedEntries.find((entry) => entry.path === renamedPath)
setSelection(refreshedEntry ? { kind: 'entity', entry: refreshedEntry } : DEFAULT_SELECTION)
}
export function updateTabsAfterFolderRename(params: {
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
refreshedEntries: VaultEntry[]
renameResult: FolderRenameResult
setTabs: React.Dispatch<React.SetStateAction<FolderTab[]>>
vaultPath: string
}) {
const {
activeTabPathRef,
handleSwitchTab,
refreshedEntries,
renameResult,
setTabs,
vaultPath,
} = params
const oldPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.old_path })
const newPrefix = folderAbsolutePath({ vaultPath, folderPath: renameResult.new_path })
setTabs((currentTabs) => currentTabs.map((tab) => {
if (!isWithinPrefix({ path: tab.entry.path, prefix: oldPrefix })) return tab
const nextPath = replaceFolderPrefix({ path: tab.entry.path, oldPrefix, newPrefix })
const refreshedEntry = refreshedEntries.find((entry) => entry.path === nextPath)
return refreshedEntry ? { ...tab, entry: refreshedEntry } : { ...tab, entry: { ...tab.entry, path: nextPath } }
}))
const activeTabPath = activeTabPathRef.current
if (!activeTabPath || !isWithinPrefix({ path: activeTabPath, prefix: oldPrefix })) return
handleSwitchTab(replaceFolderPrefix({ path: activeTabPath, oldPrefix, newPrefix }))
}
export function clearDeletedFolderTabs(params: {
activeTabPathRef: React.MutableRefObject<string | null>
closeAllTabs: () => void
folderPath: string
setTabs: React.Dispatch<React.SetStateAction<FolderTab[]>>
vaultPath: string
}) {
const {
activeTabPathRef,
closeAllTabs,
folderPath,
setTabs,
vaultPath,
} = params
const folderPrefix = folderAbsolutePath({ vaultPath, folderPath })
const hasActiveTabInFolder = !!activeTabPathRef.current
&& isWithinPrefix({ path: activeTabPathRef.current, prefix: folderPrefix })
if (hasActiveTabInFolder) {
closeAllTabs()
return
}
setTabs((currentTabs) => currentTabs.filter((tab) => !isWithinPrefix({ path: tab.entry.path, prefix: folderPrefix })))
}
export function resetSelectionIfFolderDeleted(params: {
folderPath: string
refreshedEntries: VaultEntry[]
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
vaultPath: string
}) {
const {
folderPath,
refreshedEntries,
selection,
setSelection,
vaultPath,
} = params
if (selection.kind === 'folder' && isWithinPrefix({ path: selection.path, prefix: folderPath })) {
setSelection(DEFAULT_SELECTION)
return
}
if (selection.kind !== 'entity') return
const folderPrefix = folderAbsolutePath({ vaultPath, folderPath })
if (!isWithinPrefix({ path: selection.entry.path, prefix: folderPrefix })) return
const replacement = refreshedEntries.find((entry) => entry.path === selection.entry.path)
setSelection(replacement ? { kind: 'entity', entry: replacement } : DEFAULT_SELECTION)
}

View File

@@ -0,0 +1,92 @@
import { useCallback, useState } from 'react'
import type { SidebarSelection, VaultEntry } from '../../types'
import {
clearDeletedFolderTabs,
type ConfirmFolderDeleteState,
type FolderTab,
folderLabel,
invokeDeleteFolder,
resetSelectionIfFolderDeleted,
} from './folderActionUtils'
interface UseFolderDeleteInput {
activeTabPathRef: React.MutableRefObject<string | null>
clearFolderRename: () => void
closeAllTabs: () => void
reloadFolders: () => Promise<unknown>
reloadVault: () => Promise<VaultEntry[]>
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setTabs: React.Dispatch<React.SetStateAction<FolderTab[]>>
setToastMessage: (message: string | null) => void
vaultPath: string
}
export function useFolderDelete({
activeTabPathRef,
clearFolderRename,
closeAllTabs,
reloadFolders,
reloadVault,
selection,
setSelection,
setTabs,
setToastMessage,
vaultPath,
}: UseFolderDeleteInput) {
const [confirmDeleteFolder, setConfirmDeleteFolder] = useState<ConfirmFolderDeleteState | null>(null)
const requestDeleteFolder = useCallback((folderPath: string) => {
clearFolderRename()
setConfirmDeleteFolder({
path: folderPath,
title: `Delete "${folderLabel({ folderPath })}" and everything inside it?`,
message: 'This permanently removes the folder, all nested folders, and every note or file inside it. This cannot be undone.',
confirmLabel: 'Delete folder',
})
}, [clearFolderRename])
const cancelDeleteFolder = useCallback(() => setConfirmDeleteFolder(null), [])
const confirmDeleteSelectedFolder = useCallback(async () => {
if (!confirmDeleteFolder) return
const folderPath = confirmDeleteFolder.path
try {
setConfirmDeleteFolder(null)
await invokeDeleteFolder({ vaultPath, folderPath })
clearDeletedFolderTabs({
activeTabPathRef,
closeAllTabs,
folderPath,
setTabs,
vaultPath,
})
await reloadFolders()
const refreshedEntries = await reloadVault()
resetSelectionIfFolderDeleted({
folderPath,
refreshedEntries,
selection,
setSelection,
vaultPath,
})
setToastMessage(`Deleted folder "${folderLabel({ folderPath })}"`)
} catch (error) {
setToastMessage(`Failed to delete folder: ${error}`)
}
}, [activeTabPathRef, closeAllTabs, confirmDeleteFolder, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath])
const deleteSelectedFolder = useCallback(() => {
if (selection.kind !== 'folder') return
requestDeleteFolder(selection.path)
}, [requestDeleteFolder, selection])
return {
cancelDeleteFolder,
confirmDeleteFolder,
confirmDeleteSelectedFolder,
deleteSelectedFolder,
requestDeleteFolder,
}
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useState } from 'react'
import type { SidebarSelection, VaultEntry } from '../../types'
import {
folderLabel,
invokeRenameFolder,
type FolderTab,
updateSelectionAfterFolderRename,
updateTabsAfterFolderRename,
} from './folderActionUtils'
interface UseFolderRenameInput {
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
reloadFolders: () => Promise<unknown>
reloadVault: () => Promise<VaultEntry[]>
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setTabs: React.Dispatch<React.SetStateAction<FolderTab[]>>
setToastMessage: (message: string | null) => void
vaultPath: string
}
export function useFolderRename({
activeTabPathRef,
handleSwitchTab,
reloadFolders,
reloadVault,
selection,
setSelection,
setTabs,
setToastMessage,
vaultPath,
}: UseFolderRenameInput) {
const [renamingFolderPath, setRenamingFolderPath] = useState<string | null>(null)
const cancelFolderRename = useCallback(() => setRenamingFolderPath(null), [])
const startFolderRename = useCallback((folderPath: string) => setRenamingFolderPath(folderPath), [])
const renameFolder = useCallback(async (folderPath: string, nextName: string) => {
const trimmedName = nextName.trim()
if (trimmedName === folderLabel({ folderPath })) {
setRenamingFolderPath(null)
return true
}
try {
const renameResult = await invokeRenameFolder({ vaultPath, folderPath, newName: trimmedName })
setRenamingFolderPath(null)
await reloadFolders()
const refreshedEntries = await reloadVault()
updateTabsAfterFolderRename({
activeTabPathRef,
handleSwitchTab,
refreshedEntries,
renameResult,
setTabs,
vaultPath,
})
updateSelectionAfterFolderRename({
refreshedEntries,
renameResult,
selection,
setSelection,
vaultPath,
})
setToastMessage(`Renamed folder to "${trimmedName}"`)
return true
} catch (error) {
setToastMessage(`Failed to rename folder: ${error}`)
return false
}
}, [activeTabPathRef, handleSwitchTab, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath])
const renameSelectedFolder = useCallback(() => {
if (selection.kind !== 'folder') return
startFolderRename(selection.path)
}, [selection, startFolderRename])
return {
cancelFolderRename,
renameFolder,
renameSelectedFolder,
renamingFolderPath,
startFolderRename,
}
}

View File

@@ -44,6 +44,8 @@ interface AppCommandsConfig {
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onRenameFolder?: () => void
onDeleteFolder?: () => void
showInbox?: boolean
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
@@ -78,6 +80,9 @@ interface AppCommandsConfig {
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
activeNoteHasIcon?: boolean
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
@@ -100,6 +105,8 @@ type CommandRegistrySelectionState = Pick<
| 'onZoomReset'
| 'zoomLevel'
| 'onSelect'
| 'onRenameFolder'
| 'onDeleteFolder'
| 'showInbox'
| 'onGoBack'
| 'onGoForward'
@@ -165,6 +172,9 @@ type CommandRegistryNoteActions = Pick<
CommandRegistryConfig,
| 'onSetNoteIcon'
| 'onRemoveNoteIcon'
| 'onChangeNoteType'
| 'onMoveNoteToFolder'
| 'canMoveNoteToFolder'
| 'activeNoteHasIcon'
| 'noteListFilter'
| 'onSetNoteListFilter'
@@ -334,6 +344,8 @@ function createCommandRegistrySelectionConfig(
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onRenameFolder: config.onRenameFolder,
onDeleteFolder: config.onDeleteFolder,
showInbox: config.showInbox,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
@@ -415,6 +427,9 @@ function createCommandRegistryNoteConfig(
return {
onSetNoteIcon: config.onSetNoteIcon,
onRemoveNoteIcon: config.onRemoveNoteIcon,
onChangeNoteType: config.onChangeNoteType,
onMoveNoteToFolder: config.onMoveNoteToFolder,
canMoveNoteToFolder: config.canMoveNoteToFolder,
activeNoteHasIcon: config.activeNoteHasIcon,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,

View File

@@ -43,6 +43,8 @@ function makeActions() {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
multiSelectionCommandRef: { current: null },
}
@@ -283,6 +285,38 @@ describe('useAppKeyboard', () => {
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
it('Cmd+Left does not go back when contenteditable is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedContentEditable((editable) => {
fireKeyOnTarget(editable, 'ArrowLeft', { metaKey: true, code: 'ArrowLeft' })
expect(actions.onGoBack).not.toHaveBeenCalled()
})
})
it('Cmd+Right does not go forward when contenteditable is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedContentEditable((editable) => {
fireKeyOnTarget(editable, 'ArrowRight', { metaKey: true, code: 'ArrowRight' })
expect(actions.onGoForward).not.toHaveBeenCalled()
})
})
it('Cmd+Left still goes back when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('ArrowLeft', { metaKey: true, code: 'ArrowLeft' })
expect(actions.onGoBack).toHaveBeenCalled()
})
it('Cmd+Right still goes forward when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('ArrowRight', { metaKey: true, code: 'ArrowRight' })
expect(actions.onGoForward).toHaveBeenCalled()
})
it('Cmd+K still works when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -152,6 +152,42 @@ describe('useCommandRegistry', () => {
expect(onSetNoteIcon).toHaveBeenCalled()
})
it('includes Change Note Type when the active note can be retargeted', () => {
const onChangeNoteType = vi.fn()
const config = makeConfig({ onChangeNoteType })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'change-note-type')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(onChangeNoteType).toHaveBeenCalledOnce()
})
it('enables Move Note to Folder only when another folder destination exists', () => {
const onMoveNoteToFolder = vi.fn()
const { result, rerender } = renderHook(
(props) => useCommandRegistry(props),
{
initialProps: makeConfig({
onMoveNoteToFolder,
canMoveNoteToFolder: true,
}),
},
)
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(true)
findCommand(result.current, 'move-note-to-folder')!.execute()
expect(onMoveNoteToFolder).toHaveBeenCalledOnce()
rerender(makeConfig({
onMoveNoteToFolder,
canMoveNoteToFolder: false,
}))
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(false)
})
it('includes restore deleted note command when provided', () => {
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true })
const { result } = renderHook(() => useCommandRegistry(config))
@@ -245,6 +281,47 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'go-inbox')).toBeUndefined()
})
it('enables folder commands when a folder is selected', () => {
const config = makeConfig({
selection: { kind: 'folder', path: 'projects' },
onRenameFolder: vi.fn(),
onDeleteFolder: vi.fn(),
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(true)
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(true)
})
it('disables folder commands outside folder selection', () => {
const config = makeConfig({
selection: { kind: 'filter', filter: 'all' },
onRenameFolder: vi.fn(),
onDeleteFolder: vi.fn(),
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(false)
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(false)
})
it('executes folder command callbacks', () => {
const onRenameFolder = vi.fn()
const onDeleteFolder = vi.fn()
const config = makeConfig({
selection: { kind: 'folder', path: 'projects' },
onRenameFolder,
onDeleteFolder,
})
const { result } = renderHook(() => useCommandRegistry(config))
findCommand(result.current, 'rename-folder')!.execute()
findCommand(result.current, 'delete-folder')!.execute()
expect(onRenameFolder).toHaveBeenCalledTimes(1)
expect(onDeleteFolder).toHaveBeenCalledTimes(1)
})
it('omits the removed daily-note command', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
@@ -421,15 +498,15 @@ describe('install-mcp command', () => {
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Install MCP Server')
expect(cmd!.label).toBe('Set Up External AI Tools…')
})
it('is enabled when mcpStatus is installed and handler provided (restore use case)', () => {
it('is enabled when mcpStatus is installed and handler provided (manage use case)', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Restore MCP Server')
expect(cmd!.label).toBe('Manage External AI Tools…')
})
it('is enabled even when mcpStatus is checking', () => {
@@ -446,13 +523,14 @@ describe('install-mcp command', () => {
expect(cmd!.enabled).toBe(true)
})
it('has restore keyword for discoverability', () => {
it('has setup keywords for discoverability', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.keywords).toContain('restore')
expect(cmd!.keywords).toContain('setup')
expect(cmd!.keywords).toContain('external')
expect(cmd!.keywords).toContain('mcp')
expect(cmd!.keywords).toContain('claude')
expect(cmd!.keywords).toContain('cursor')
})
it('executes onInstallMcp callback', () => {

View File

@@ -40,6 +40,9 @@ interface CommandRegistryConfig {
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onChangeNoteType?: () => void
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
@@ -77,6 +80,8 @@ interface CommandRegistryConfig {
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onRenameFolder?: () => void
onDeleteFolder?: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
@@ -99,7 +104,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
onSelect, onRenameFolder, onDeleteFolder,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
@@ -107,7 +112,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
onRestoreDeletedNote, canRestoreDeletedNote,
@@ -132,11 +137,23 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => [
...buildNavigationCommands({ onQuickOpen, onSelect, showInbox, onGoBack, onGoForward, canGoBack, canGoForward }),
...buildNavigationCommands({
onQuickOpen,
onSelect,
selection,
onRenameFolder,
onDeleteFolder,
showInbox,
onGoBack,
onGoForward,
canGoBack,
canGoForward,
}),
...buildNoteCommands({
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
@@ -179,7 +196,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault, config.canAddRemote, config.onAddRemote,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
onSelect, onRenameFolder, onDeleteFolder,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
@@ -187,8 +204,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
isSectionGroup, noteListFilter, onSetNoteListFilter,
selection,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
onRestoreDeletedNote, canRestoreDeletedNote, activeEntry,

View File

@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from 'react'
import { renderHook, act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarSelection, VaultEntry } from '../types'
import { useFolderActions } from './useFolderActions'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri')
const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
const folderEntry: VaultEntry = {
path: '/vault/projects/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 10,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
}
function renderFolderActions({
initialSelection,
initialTabs = [{ entry: folderEntry, content: '# Note' }],
reloadVault,
reloadFolders,
setToastMessage,
}: {
initialSelection: SidebarSelection
initialTabs?: Array<{ entry: VaultEntry; content: string }>
reloadVault: ReturnType<typeof vi.fn>
reloadFolders: ReturnType<typeof vi.fn>
setToastMessage: ReturnType<typeof vi.fn>
}) {
return renderHook(() => {
const [selection, setSelection] = useState<SidebarSelection>(initialSelection)
const [tabs, setTabs] = useState(initialTabs)
const [activeTabPath, setActiveTabPath] = useState<string | null>(initialTabs[0]?.entry.path ?? null)
const activeTabPathRef = useRef(activeTabPath)
useEffect(() => {
activeTabPathRef.current = activeTabPath
}, [activeTabPath])
const actions = useFolderActions({
vaultPath: '/vault',
selection,
setSelection,
setTabs,
activeTabPathRef,
handleSwitchTab: setActiveTabPath,
closeAllTabs: () => {
setTabs([])
setActiveTabPath(null)
},
reloadVault,
reloadFolders,
setToastMessage,
})
return { actions, selection, tabs, activeTabPath }
})
}
describe('useFolderActions', () => {
let reloadVault: ReturnType<typeof vi.fn>
let reloadFolders: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
mockInvokeFn.mockReset()
reloadVault = vi.fn()
reloadFolders = vi.fn().mockResolvedValue([])
setToastMessage = vi.fn()
})
it('renames a selected folder and updates selection plus active tab path', async () => {
const renamedEntry = { ...folderEntry, path: '/vault/work/note.md' }
reloadVault.mockResolvedValue([renamedEntry])
mockInvokeFn.mockResolvedValue({ old_path: 'projects', new_path: 'work' })
const { result } = renderFolderActions({
initialSelection: { kind: 'folder', path: 'projects' },
reloadVault,
reloadFolders,
setToastMessage,
})
await act(async () => {
await result.current.actions.renameFolder('projects', 'work')
})
expect(result.current.selection).toEqual({ kind: 'folder', path: 'work' })
expect(result.current.tabs[0]?.entry.path).toBe('/vault/work/note.md')
expect(result.current.activeTabPath).toBe('/vault/work/note.md')
expect(setToastMessage).toHaveBeenCalledWith('Renamed folder to "work"')
})
it('deletes a selected folder and clears the active note gracefully', async () => {
reloadVault.mockResolvedValue([])
mockInvokeFn.mockResolvedValue('projects')
const { result } = renderFolderActions({
initialSelection: { kind: 'folder', path: 'projects' },
reloadVault,
reloadFolders,
setToastMessage,
})
act(() => {
result.current.actions.requestDeleteFolder('projects')
})
await act(async () => {
await result.current.actions.confirmDeleteSelectedFolder()
})
expect(result.current.selection).toEqual({ kind: 'filter', filter: 'all' })
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(setToastMessage).toHaveBeenCalledWith('Deleted folder "projects"')
})
})

View File

@@ -0,0 +1,59 @@
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
import type { FolderTab } from './folder-actions/folderActionUtils'
import { useFolderDelete } from './folder-actions/useFolderDelete'
import { useFolderRename } from './folder-actions/useFolderRename'
interface UseFolderActionsInput {
vaultPath: string
selection: SidebarSelection
setSelection: (selection: SidebarSelection) => void
setTabs: React.Dispatch<React.SetStateAction<FolderTab[]>>
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
closeAllTabs: () => void
reloadVault: () => Promise<VaultEntry[]>
reloadFolders: () => Promise<FolderNode[]>
setToastMessage: (message: string | null) => void
}
export function useFolderActions({
vaultPath,
selection,
setSelection,
setTabs,
activeTabPathRef,
handleSwitchTab,
closeAllTabs,
reloadVault,
reloadFolders,
setToastMessage,
}: UseFolderActionsInput) {
const renameActions = useFolderRename({
activeTabPathRef,
handleSwitchTab,
reloadFolders,
reloadVault,
selection,
setSelection,
setTabs,
setToastMessage,
vaultPath,
})
const deleteActions = useFolderDelete({
activeTabPathRef,
clearFolderRename: renameActions.cancelFolderRename,
closeAllTabs,
reloadFolders,
reloadVault,
selection,
setSelection,
setTabs,
setToastMessage,
vaultPath,
})
return {
...renameActions,
...deleteActions,
}
}

View File

@@ -13,166 +13,151 @@ vi.mock('../mock-tauri', () => ({
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
function mockCommands(handlers: Partial<Record<string, unknown>>) {
mockInvoke.mockImplementation((command: string) => {
if (command in handlers) return Promise.resolve(handlers[command])
return Promise.resolve(null)
})
}
function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
) {
mockInvoke.mockImplementation((command: string) => {
if (command === 'check_mcp_status') return Promise.resolve(initialStatus)
if (command in overrides) {
const result = overrides[command as keyof typeof overrides]
if (result instanceof Error) return Promise.reject(result)
return Promise.resolve(result)
}
return Promise.resolve(null)
})
}
async function renderReadySubject(initialStatus: 'installed' | 'not_installed') {
const onToast = vi.fn()
const hook = renderSubject(onToast)
await waitFor(() => {
expect(hook.result.current.mcpStatus).toBe(initialStatus)
})
return { onToast, ...hook }
}
async function runMutationScenario({
action,
initialStatus,
overrideKey,
overrideValue,
}: {
action: 'connect' | 'disconnect'
initialStatus: 'installed' | 'not_installed'
overrideKey: 'register_mcp_tools' | 'remove_mcp_tools'
overrideValue: unknown
}) {
mockStatusFlow(initialStatus, { [overrideKey]: overrideValue })
const hook = await renderReadySubject(initialStatus)
await act(async () => {
if (action === 'connect') {
await hook.result.current.connectMcp()
return
}
await hook.result.current.disconnectMcp()
})
return hook
}
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves to installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
it('checks the active vault status without auto-registering on mount', async () => {
mockCommands({
check_mcp_status: 'installed',
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
const { result } = renderSubject()
expect(result.current.mcpStatus).toBe('checking')
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
expect(mockInvoke).toHaveBeenCalledWith('check_mcp_status', { vaultPath: '/vault' })
expect(mockInvoke).not.toHaveBeenCalledWith('register_mcp_tools', { vaultPath: '/vault' })
})
it('resolves to not_installed when check returns not_installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('fail'))
return Promise.resolve(null)
it('resolves to not_installed when the active vault is not connected', async () => {
mockCommands({
check_mcp_status: 'not_installed',
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
})
it('resolves to no_claude_cli when check returns no_claude_cli', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('no_claude_cli')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no cli'))
return Promise.resolve(null)
it.each([
{
action: 'connect' as const,
commandArgs: { vaultPath: '/vault' },
expectedStatus: 'installed' as const,
initialStatus: 'not_installed' as const,
name: 'connects external AI tools for the current vault on demand',
overrideKey: 'register_mcp_tools' as const,
overrideValue: 'registered',
toastFragment: 'Tolaria external AI tools connected successfully',
},
{
action: 'connect' as const,
commandArgs: { vaultPath: '/vault' },
expectedStatus: 'not_installed' as const,
initialStatus: 'not_installed' as const,
name: 'reports setup failures without mutating the active status silently',
overrideKey: 'register_mcp_tools' as const,
overrideValue: new Error('disk full'),
toastFragment: 'External AI tool setup failed',
},
{
action: 'disconnect' as const,
commandArgs: undefined,
expectedStatus: 'not_installed' as const,
initialStatus: 'installed' as const,
name: 'disconnects external AI tools explicitly',
overrideKey: 'remove_mcp_tools' as const,
overrideValue: 'removed',
toastFragment: 'Tolaria external AI tools disconnected successfully',
},
{
action: 'disconnect' as const,
commandArgs: undefined,
expectedStatus: 'installed' as const,
initialStatus: 'installed' as const,
name: 'refreshes status after a disconnect failure',
overrideKey: 'remove_mcp_tools' as const,
overrideValue: new Error('permission denied'),
toastFragment: 'External AI tool disconnect failed',
},
])('$name', async ({ action, commandArgs, expectedStatus, initialStatus, overrideKey, overrideValue, toastFragment }) => {
const { result, onToast } = await runMutationScenario({
action,
initialStatus,
overrideKey,
overrideValue,
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('no_claude_cli')
})
})
it('install action calls register_mcp_tools and updates status', async () => {
// Auto-register fails (e.g. node not found), leaving status as not_installed
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no node'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
// Now manual install succeeds
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('Tolaria MCP server installed successfully')
})
it('install action shows error toast on failure', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('disk full'))
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('not_installed')
expect(onToast).toHaveBeenCalledWith(expect.stringContaining('MCP install failed'))
})
it('shows toast when registered for the first time', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith('Tolaria registered as MCP tool for Claude Code')
})
})
it('install action shows restored toast when status was installed', async () => {
// First call: status already installed
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
// Clear toasts from auto-register
onToast.mockClear()
// Now manually trigger install (restore)
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('Tolaria MCP server restored successfully')
})
it('does not show toast when already registered', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('register_mcp_tools', { vaultPath: '/vault' })
})
// 'updated' should not trigger a toast
expect(onToast).not.toHaveBeenCalledWith('Tolaria registered as MCP tool for Claude Code')
expect(result.current.mcpStatus).toBe(expectedStatus)
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
})

View File

@@ -2,74 +2,94 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed' | 'no_claude_cli'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
/**
* Detects MCP server status on vault open and provides an install action.
*
* Combines the old `useMcpRegistration` functionality (auto-register on vault
* switch) with new status detection for the status bar indicator.
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const statusRef = useRef<McpStatus>(status)
const registeredRef = useRef<string | null>(null)
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
useEffect(() => { statusRef.current = status }, [status])
// Check MCP status on vault open / vault switch
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
setStatus(nextStatus)
return nextStatus
}, [vaultPath])
useEffect(() => {
let cancelled = false
setStatus('checking') // eslint-disable-line react-hooks/set-state-in-effect -- reset to checking on vault switch
tauriCall<string>('check_mcp_status')
.then((result) => {
if (!cancelled) setStatus(result as McpStatus)
})
.catch(() => {
if (!cancelled) setStatus('not_installed')
})
fetchMcpStatus(vaultPath).then((nextStatus) => {
if (!cancelled) setStatus(nextStatus)
})
return () => { cancelled = true }
}, [vaultPath])
// Auto-register on vault switch (preserves old useMcpRegistration behavior)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((result) => {
if (result === 'registered') {
onToastRef.current('Tolaria registered as MCP tool for Claude Code')
}
setStatus('installed')
})
.catch(() => {
// Non-critical — status check will show the right state
})
}, [vaultPath])
const install = useCallback(async () => {
const wasInstalled = statusRef.current === 'installed'
const connectMcp = useCallback(async () => {
setStatus('checking')
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
const result = await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current(wasInstalled ? 'Tolaria MCP server restored successfully' : 'Tolaria MCP server installed successfully')
onToastRef.current(connectSuccessToast(result))
return true
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)
onToastRef.current(`External AI tool setup failed: ${e}`)
return false
}
}, [vaultPath])
return { mcpStatus: status, installMcp: install }
const disconnectMcp = useCallback(async () => {
setStatus('checking')
try {
const result = await tauriCall<string>('remove_mcp_tools')
setStatus('not_installed')
onToastRef.current(disconnectSuccessToast(result))
return true
} catch (e) {
const nextStatus = await refreshMcpStatus()
setStatus(nextStatus)
onToastRef.current(`External AI tool disconnect failed: ${e}`)
return false
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
}

View File

@@ -70,18 +70,48 @@ describe('useNoteActions hook', () => {
vi.useRealTimers()
})
it('handleCreateNote calls addEntry and creates correct entry', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
function renderActions(entries: VaultEntry[] = []) {
return renderHook(() => useNoteActions(makeConfig(entries)))
}
function createImmediateEntry(type?: string) {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderActions()
act(() => {
result.current.handleCreateNoteImmediate(type)
})
const [createdEntry] = addEntry.mock.calls[0]
vi.restoreAllMocks()
return createdEntry as VaultEntry
}
it.each([
{
name: 'handleCreateNote',
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateNote('Test Note', 'Note'),
expectedTitle: 'Test Note',
expectedType: 'Note',
expectedPathFragment: 'test-note.md',
},
{
name: 'handleCreateType',
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateType('Recipe'),
expectedTitle: 'Recipe',
expectedType: 'Type',
expectedPathFragment: 'recipe.md',
},
])('$name creates the expected entry', ({ run, expectedTitle, expectedType, expectedPathFragment }) => {
const { result } = renderActions()
act(() => {
result.current.handleCreateNote('Test Note', 'Note')
run(result)
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Test Note')
expect(createdEntry.isA).toBe('Note')
expect(createdEntry.path).toContain('test-note.md')
expect(createdEntry.title).toBe(expectedTitle)
expect(createdEntry.isA).toBe(expectedType)
expect(createdEntry.path).toContain(expectedPathFragment)
})
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
@@ -102,19 +132,6 @@ describe('useNoteActions hook', () => {
expect(result.current.activeTabPath).toContain('fast-note.md')
})
it('handleCreateType creates type entry', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateType('Recipe')
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.isA).toBe('Type')
expect(createdEntry.title).toBe('Recipe')
})
it('handleNavigateWikilink finds entry by title', async () => {
const target = makeEntry({ title: 'Target Note', path: '/vault/target.md' })
@@ -178,19 +195,10 @@ describe('useNoteActions hook', () => {
})
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNoteImmediate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
const createdEntry = createImmediateEntry()
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
@@ -217,18 +225,9 @@ describe('useNoteActions hook', () => {
})
it('handleCreateNoteImmediate accepts custom type', () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
result.current.handleCreateNoteImmediate('Project')
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
const createdEntry = createImmediateEntry('Project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
vi.restoreAllMocks()
})
it('handleCreateNote uses default template for Project type', () => {
@@ -435,7 +434,11 @@ describe('useNoteActions hook', () => {
expect(addEntry).toHaveBeenCalledTimes(1)
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
expect(setToastMessage).toHaveBeenCalledWith(
type === 'Type'
? 'Failed to create type — disk write error'
: 'Failed to create note — disk write error',
)
})
it('does not revert when disk write succeeds', async () => {

Some files were not shown because too many files have changed in this diff Show More