Compare commits
27 Commits
stable-v20
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c6bb6c57 | ||
|
|
35dd2dd47b | ||
|
|
89141bae14 | ||
|
|
2b9294f884 | ||
|
|
a756af54ec | ||
|
|
b01b156dfb | ||
|
|
2c9361d704 | ||
|
|
cea71a4fd9 | ||
|
|
8b73ef5836 | ||
|
|
a6a727a4c0 | ||
|
|
bf13eed3ab | ||
|
|
a27a70e552 | ||
|
|
ca92bf4f77 | ||
|
|
9fda0a67ec | ||
|
|
0ffb7c65a9 | ||
|
|
633d9f1496 | ||
|
|
ba3d413b94 | ||
|
|
f3286922ad | ||
|
|
b0b476c9e5 | ||
|
|
dac696b752 | ||
|
|
cda87ee6c0 | ||
|
|
33324a5c79 | ||
|
|
f28626bbfe | ||
|
|
1bc2e3d1e0 | ||
|
|
8e6bdab3e6 | ||
|
|
d52362ad9b | ||
|
|
bf442646f5 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.77
|
||||
HOTSPOT_THRESHOLD=9.94
|
||||
AVERAGE_THRESHOLD=9.81
|
||||
|
||||
@@ -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 project’s trademark policy.
|
||||
|
||||
55
SECURITY.md
Normal file
55
SECURITY.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
docs/adr/0072-confirmed-vault-paths-gate-startup-state.md
Normal file
33
docs/adr/0072-confirmed-vault-paths-gate-startup-state.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
101
patches/@tiptap__extension-link@3.19.0.patch
Normal file
101
patches/@tiptap__extension-link@3.19.0.patch
Normal 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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"))?
|
||||
}
|
||||
|
||||
23
src-tauri/src/commands/folders.rs
Normal file
23
src-tauri/src/commands/folders.rs
Normal 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)
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬e_path);
|
||||
vault::auto_rename_untitled(&vault_path, ¬e_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(¬e).unwrap();
|
||||
@@ -372,22 +91,102 @@ mod tests {
|
||||
let note = dir.path().join("test.md");
|
||||
std::fs::write(¬e, "---\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(¬e, "---\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(
|
||||
|
||||
282
src-tauri/src/commands/vault/boundary.rs
Normal file
282
src-tauri/src/commands/vault/boundary.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
170
src-tauri/src/commands/vault/file_cmds.rs
Normal file
170
src-tauri/src/commands/vault/file_cmds.rs
Normal 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)
|
||||
}
|
||||
48
src-tauri/src/commands/vault/frontmatter_cmds.rs
Normal file
48
src-tauri/src/commands/vault/frontmatter_cmds.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
90
src-tauri/src/commands/vault/lifecycle_cmds.rs
Normal file
90
src-tauri/src/commands/vault/lifecycle_cmds.rs
Normal 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())
|
||||
}
|
||||
108
src-tauri/src/commands/vault/rename_cmds.rs
Normal file
108
src-tauri/src/commands/vault/rename_cmds.rs
Normal 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,
|
||||
¬e_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)
|
||||
}
|
||||
51
src-tauri/src/commands/vault/scan_cmds.rs
Normal file
51
src-tauri/src/commands/vault/scan_cmds.rs
Normal 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))?
|
||||
}
|
||||
37
src-tauri/src/commands/vault/view_cmds.rs
Normal file
37
src-tauri/src/commands/vault/view_cmds.rs
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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""#);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
183
src-tauri/src/vault/folders.rs
Normal file
183
src-tauri/src/vault/folders.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
287
src-tauri/src/vault/rename_transaction.rs
Normal file
287
src-tauri/src/vault/rename_transaction.rs
Normal 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(())
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
536
src/App.tsx
536
src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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={() => {}} />)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
src/components/McpSetupDialog.test.tsx
Normal file
65
src/components/McpSetupDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
109
src/components/McpSetupDialog.tsx
Normal file
109
src/components/McpSetupDialog.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
54
src/components/folder-tree/FolderContextMenu.tsx
Normal file
54
src/components/folder-tree/FolderContextMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
src/components/folder-tree/FolderItemRow.tsx
Normal file
228
src/components/folder-tree/FolderItemRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/components/folder-tree/FolderNameInput.tsx
Normal file
74
src/components/folder-tree/FolderNameInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
src/components/folder-tree/FolderTreeRow.tsx
Normal file
171
src/components/folder-tree/FolderTreeRow.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
22
src/components/folder-tree/folderTreeUtils.ts
Normal file
22
src/components/folder-tree/folderTreeUtils.ts
Normal 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
|
||||
}
|
||||
65
src/components/folder-tree/useFolderContextMenu.ts
Normal file
65
src/components/folder-tree/useFolderContextMenu.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
56
src/components/folder-tree/useFolderRowInteractions.ts
Normal file
56
src/components/folder-tree/useFolderRowInteractions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
98
src/components/folder-tree/useFolderTreeDisclosure.ts
Normal file
98
src/components/folder-tree/useFolderTreeDisclosure.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
src/components/note-retargeting/DraggableNoteItem.tsx
Normal file
29
src/components/note-retargeting/DraggableNoteItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
src/components/note-retargeting/NoteDropTarget.test.tsx
Normal file
99
src/components/note-retargeting/NoteDropTarget.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
92
src/components/note-retargeting/NoteDropTarget.tsx
Normal file
92
src/components/note-retargeting/NoteDropTarget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/note-retargeting/NoteRetargetingContext.tsx
Normal file
19
src/components/note-retargeting/NoteRetargetingContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/note-retargeting/NoteRetargetingDialogs.tsx
Normal file
62
src/components/note-retargeting/NoteRetargetingDialogs.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
174
src/components/note-retargeting/RetargetNoteDialog.tsx
Normal file
174
src/components/note-retargeting/RetargetNoteDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/note-retargeting/noteDragData.ts
Normal file
27
src/components/note-retargeting/noteDragData.ts
Normal 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
|
||||
}
|
||||
24
src/components/note-retargeting/noteRetargetingContext.ts
Normal file
24
src/components/note-retargeting/noteRetargetingContext.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
154
src/components/sidebar/useSidebarTypeInteractions.ts
Normal file
154
src/components/sidebar/useSidebarTypeInteractions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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?.() },
|
||||
]
|
||||
|
||||
204
src/hooks/folder-actions/folderActionUtils.ts
Normal file
204
src/hooks/folder-actions/folderActionUtils.ts
Normal 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)
|
||||
}
|
||||
92
src/hooks/folder-actions/useFolderDelete.ts
Normal file
92
src/hooks/folder-actions/useFolderDelete.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
86
src/hooks/folder-actions/useFolderRename.ts
Normal file
86
src/hooks/folder-actions/useFolderRename.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
src/hooks/useFolderActions.test.ts
Normal file
149
src/hooks/useFolderActions.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
59
src/hooks/useFolderActions.ts
Normal file
59
src/hooks/useFolderActions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user