diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 0d382332..cc2070e8 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -159,7 +159,7 @@ interface VaultEntry { | `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics | | `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback | -Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects. +Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. The "open in default app" action re-enters the vault command boundary through `open_vault_file_external` before delegating to the native opener, so preview and external-open access stay scoped to the active vault. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects. ### Note Content Freshness @@ -356,7 +356,7 @@ A `vault_health_check` command detects stray files in non-protected subfolders a 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, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately. -UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary. +UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary. The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 69861ae6..e0561c04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -194,7 +194,7 @@ flowchart TD - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, 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. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. 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 and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. +- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. 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). Panels are separated by `ResizeHandle` components that support drag-to-resize. @@ -676,6 +676,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `batch_delete_notes` | Permanently delete notes from disk | | `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec` | | `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | +| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app | | `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events | | `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 | diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8be532e8..f51649bf 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -17,7 +17,6 @@ "dialog:default", "updater:default", "process:default", - "opener:default", - "opener:allow-open-path" + "opener:default" ] } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 3a522185..83fddbfa 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -149,8 +149,6 @@ pub async fn stream_ai_agent( mod tests { use super::*; use crate::vault::AiGuidanceFileState; - use serde_json::Value; - use std::{fs, path::Path}; #[test] fn guidance_commands_report_and_restore_vault_guidance_files() { @@ -173,24 +171,4 @@ mod tests { assert!(dir.path().join("CLAUDE.md").exists()); assert!(dir.path().join("GEMINI.md").exists()); } - - #[test] - fn desktop_capability_allows_opening_restored_guidance_files() { - let capability_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("capabilities") - .join("default.json"); - let capability_json = fs::read_to_string(capability_path).unwrap(); - let capability: Value = serde_json::from_str(&capability_json).unwrap(); - let permissions = capability - .get("permissions") - .and_then(Value::as_array) - .unwrap(); - - assert!( - permissions - .iter() - .any(|permission| permission.as_str() == Some("opener:allow-open-path")), - "desktop capabilities must allow opener open_path so restored guidance files can be opened" - ); - } } diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index abf304c5..b81355b3 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -23,6 +23,14 @@ fn with_note_path( ) } +fn with_external_file_path( + path: &Path, + vault_path: Option<&Path>, + action: impl FnOnce(&Path) -> Result, +) -> Result { + with_note_path(path, vault_path, ValidatedPathMode::Existing, action) +} + fn with_expanded_vault_root( path: &Path, action: impl FnOnce(&Path) -> Result, @@ -75,6 +83,26 @@ pub fn sync_vault_asset_scope_for_window( }) } +#[tauri::command] +pub fn open_vault_file_external( + app_handle: tauri::AppHandle, + path: PathBuf, + vault_path: Option, +) -> Result<(), String> { + with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| { + open_path_with_default_app(&app_handle, validated_path) + }) +} + +fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + + app_handle + .opener() + .open_path(path.to_string_lossy().into_owned(), None::) + .map_err(|error| error.to_string()) +} + fn with_writable_note_path( path: PathBuf, vault_path: Option, @@ -356,6 +384,41 @@ mod tests { assert!(folder_error.contains("Path must stay inside the active vault")); } + #[test] + fn external_file_paths_accept_files_inside_requested_vault() { + let dir = TempDir::new().unwrap(); + let root = vault_root(&dir); + let attachment = note_path(&dir, "attachments/photo.png"); + fs::create_dir_all(attachment.parent().unwrap()).unwrap(); + fs::write(&attachment, "image-bytes").unwrap(); + + let validated = with_external_file_path( + attachment.as_path(), + Some(root.as_path()), + |validated_path| Ok(validated_path.to_path_buf()), + ) + .unwrap(); + + assert_eq!(validated, attachment); + } + + #[test] + fn external_file_paths_reject_files_outside_requested_vault() { + let vault = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let outside_file = outside.path().join("photo.png"); + fs::write(&outside_file, "image-bytes").unwrap(); + + let error = with_external_file_path( + outside_file.as_path(), + Some(vault.path()), + |validated_path| Ok(validated_path.to_path_buf()), + ) + .unwrap_err(); + + assert!(error.contains("Path must stay inside the active vault")); + } + #[test] fn validate_note_content_compares_against_disk() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4403a1fa..f7ed83f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -411,6 +411,7 @@ macro_rules! app_invoke_handler { commands::reload_vault, commands::reload_vault_entry, commands::sync_vault_asset_scope_for_window, + commands::open_vault_file_external, commands::sync_note_title, commands::save_image, commands::copy_image_to_vault, diff --git a/src/hooks/useConflictFlow.ts b/src/hooks/useConflictFlow.ts index 783d9604..b948e884 100644 --- a/src/hooks/useConflictFlow.ts +++ b/src/hooks/useConflictFlow.ts @@ -56,7 +56,7 @@ export function useConflictFlow({ onSelectNote(entry) closeConflictResolver() } else { - openLocalFile(fullPath) + openLocalFile(fullPath, resolvedPath) } } }, [resolvedPath, entries, onSelectNote, closeConflictResolver]) diff --git a/src/hooks/useFileActions.ts b/src/hooks/useFileActions.ts index 082f4ffd..1cec906f 100644 --- a/src/hooks/useFileActions.ts +++ b/src/hooks/useFileActions.ts @@ -39,10 +39,10 @@ export function useFileActions({ }, [setToastMessage]) const openExternalFile = useCallback((path: string) => { - void openLocalFile(path).catch((error) => { + void openLocalFile(path, vaultPath).catch((error) => { setToastMessage(fileActionErrorMessage('open file', error)) }) - }, [setToastMessage]) + }, [setToastMessage, vaultPath]) const resolveFolderPath = useCallback((folderPath: string) => ( folderAbsolutePath({ vaultPath, folderPath }) diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts index 15725d31..b8f9f2ac 100644 --- a/src/utils/url.test.ts +++ b/src/utils/url.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { openPath, revealItemInDir } from '@tauri-apps/plugin-opener' +import { invoke } from '@tauri-apps/api/core' +import { revealItemInDir } from '@tauri-apps/plugin-opener' import { copyLocalPath, normalizeExternalUrl, @@ -8,6 +9,10 @@ import { revealLocalPath, } from './url' +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + const originalClipboard = navigator.clipboard function setClipboard(writeText: (value: string) => Promise) { @@ -55,12 +60,15 @@ describe('local file actions', () => { vi.clearAllMocks() }) - it('opens local paths through the Tauri opener plugin', async () => { + it('opens local paths through the vault-scoped backend command', async () => { vi.stubGlobal('isTauri', true) - await openLocalFile('/vault/attachments/report.pdf') + await openLocalFile('/vault/attachments/report.pdf', '/vault') - expect(openPath).toHaveBeenCalledWith('/vault/attachments/report.pdf') + expect(invoke).toHaveBeenCalledWith('open_vault_file_external', { + path: '/vault/attachments/report.pdf', + vaultPath: '/vault', + }) }) it('reveals local paths through the Tauri opener plugin', async () => { diff --git a/src/utils/url.ts b/src/utils/url.ts index 83bb0ae8..d1f922c3 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -50,10 +50,12 @@ export async function openExternalUrl(url: string): Promise { } /** Open a local file path with the system default app (e.g. TextEdit for .json). */ -export async function openLocalFile(absolutePath: string): Promise { +export async function openLocalFile(absolutePath: string, vaultPath?: string): Promise { if (isTauri()) { - const { openPath } = await import('@tauri-apps/plugin-opener') - await openPath(absolutePath) + const { invoke } = await import('@tauri-apps/api/core') + const args: { path: string; vaultPath?: string } = { path: absolutePath } + if (vaultPath) args.vaultPath = vaultPath + await invoke('open_vault_file_external', args) } }