From b39840e51d351555e1b389581ef135f36acb0a48 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sat, 4 Apr 2026 22:32:49 +0900 Subject: [PATCH 1/9] feat: workspace-linked notes for @file: and Claude cwd (#461) - Tauri: safe path resolution under root, read/list workspace files, dialog plugin - Editor: FileReference @file: mark, preview dialog host, slash path completions - AI: workspace root as Claude cwd for agent slash, executable code, chat - ESLint: pass workspaceRoot string + useEditor dep instead of ref during render Made-with: Cursor --- bun.lock | 13 +- package.json | 1 + src-tauri/Cargo.lock | 98 +++++++++ src-tauri/Cargo.toml | 4 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 3 + src-tauri/src/workspace_paths.rs | 192 ++++++++++++++---- src/App.tsx | 2 + src/components/editor/TiptapEditor.tsx | 2 + .../TiptapEditor/SlashSuggestionLayer.tsx | 4 + .../editor/TiptapEditor/editorConfig.ts | 11 + .../TiptapEditor/slashSuggestionMenuProps.ts | 2 + .../editor/TiptapEditor/useEditorSetup.ts | 61 +++++- .../TiptapEditor/useSlashSuggestionMenu.ts | 14 +- .../useSlashSuggestionMenuData.ts | 8 +- .../TiptapEditor/useTiptapEditorController.ts | 10 +- .../useWorkspacePathCompletions.ts | 15 +- .../useExecutableCodeBlockController.ts | 9 +- .../extensions/FileReferenceExtension.ts | 145 +++++++++++++ src/components/note/FilePreviewDialogHost.tsx | 64 ++++++ src/components/note/NoteWorkspaceToolbar.tsx | 158 ++++++++++++++ src/contexts/NoteWorkspaceContext.tsx | 97 +++++++++ src/hooks/useAIChatExecute.ts | 8 +- src/hooks/useAIChatExecuteRegenerate.ts | 8 +- src/i18n/locales/en/editor.json | 14 ++ src/i18n/locales/ja/editor.json | 14 ++ .../executeAgentSlashCommand.ts | 9 +- src/lib/aiService.ts | 3 + .../executableCode/executeExecutableCode.ts | 2 + .../noteWorkspace/filePreviewEvents.test.ts | 18 ++ src/lib/noteWorkspace/filePreviewEvents.ts | 30 +++ src/lib/noteWorkspace/noteWorkspaceIo.ts | 51 +++++ .../noteWorkspace/noteWorkspaceStore.test.ts | 20 ++ src/lib/noteWorkspace/noteWorkspaceStore.ts | 65 ++++++ .../pickNoteWorkspaceDirectory.ts | 27 +++ src/pages/NotePageView.tsx | 34 +++- src/types/aiChat.ts | 7 + 37 files changed, 1145 insertions(+), 79 deletions(-) create mode 100644 src/components/editor/extensions/FileReferenceExtension.ts create mode 100644 src/components/note/FilePreviewDialogHost.tsx create mode 100644 src/components/note/NoteWorkspaceToolbar.tsx create mode 100644 src/contexts/NoteWorkspaceContext.tsx create mode 100644 src/lib/noteWorkspace/filePreviewEvents.test.ts create mode 100644 src/lib/noteWorkspace/filePreviewEvents.ts create mode 100644 src/lib/noteWorkspace/noteWorkspaceIo.ts create mode 100644 src/lib/noteWorkspace/noteWorkspaceStore.test.ts create mode 100644 src/lib/noteWorkspace/noteWorkspaceStore.ts create mode 100644 src/lib/noteWorkspace/pickNoteWorkspaceDirectory.ts diff --git a/bun.lock b/bun.lock index 455679fd..ff8a23f9 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@radix-ui/react-visually-hidden": "^1.2.3", "@tanstack/react-query": "^5.83.0", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-store": "^2.4.2", "@tiptap/core": "^3.20.0", @@ -1117,6 +1118,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], @@ -1993,7 +1996,7 @@ "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], @@ -3169,8 +3172,6 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], - "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], @@ -3297,8 +3298,6 @@ "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], "bun-types/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -3323,6 +3322,8 @@ "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], @@ -3413,8 +3414,6 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "react-remove-scroll/tslib": ["tslib@2.8.0", "", {}, "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="], diff --git a/package.json b/package.json index d01ece3d..09c77990 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "@radix-ui/react-visually-hidden": "^1.2.3", "@tanstack/react-query": "^5.83.0", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-store": "^2.4.2", "@tiptap/core": "^3.20.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b0c3cfb5..e9bbbfc1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1766,6 +1766,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2747,6 +2753,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2762,6 +2792,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3508,6 +3551,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -3645,6 +3728,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -5049,8 +5145,10 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-shell", "tauri-plugin-store", + "tempfile", "tokio", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 239d2f0a..6a7b272c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-store = "2" serde = { version = "1", features = ["derive"] } @@ -24,6 +25,9 @@ uuid = { version = "1", features = ["v4"] } [profile.dev] incremental = true +[dev-dependencies] +tempfile = "3" + [profile.release] codegen-units = 1 lto = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4f21ec0a..5531090a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "dialog:default", { "identifier": "shell:allow-execute", "allow": [ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d5c539c..93980c63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod workspace_paths; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::default().build()) .manage(claude_sidecar::ClaudeSidecarState::new()) .invoke_handler(tauri::generate_handler![ @@ -19,6 +20,8 @@ pub fn run() { claude_sidecar::check_claude_installation, claude_sidecar::claude_list_models, workspace_paths::list_workspace_directory_entries, + workspace_paths::read_note_workspace_file, + workspace_paths::list_note_workspace_entries, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index 79305727..7cef7a3a 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -1,7 +1,78 @@ -//! Workspace-relative directory listing for slash-command path completion. -//! スラッシュコマンドのパス補完用、ワークスペース相対ディレクトリ一覧。 +//! Workspace-relative paths: process cwd (slash completion) and note-linked roots (Issue #461). +//! プロセス cwd 基準(スラッシュ補完)とノート紐付けルート(Issue #461)。 -use std::path::PathBuf; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +/// Maximum bytes returned by {@link read_note_workspace_file}. +/// {@link read_note_workspace_file} が返す最大バイト数。 +const MAX_NOTE_WORKSPACE_FILE_BYTES: u64 = 512 * 1024; + +/// Default cap for {@link list_note_workspace_entries}. +/// {@link list_note_workspace_entries} の既定上限。 +const DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES: u32 = 500; + +/// Hard cap for list entries (API + UI abuse mitigation). +/// 列挙件数の上限(API 悪用緩和)。 +const HARD_MAX_NOTE_WORKSPACE_ENTRIES: u32 = 2000; + +/// Resolves `relative` under an already-canonicalized root (traversal-safe). +/// 正規化済みルート配下に `relative` を解決する(トラバーサル対策)。 +pub(crate) fn resolve_under_root(root_canon: &PathBuf, relative: &str) -> Result { + let trimmed = relative.trim(); + if trimmed.is_empty() { + return Ok(root_canon.clone()); + } + let mut acc = root_canon.clone(); + for comp in Path::new(trimmed).components() { + match comp { + Component::Normal(c) => { + acc.push(c); + if acc.exists() { + let canon = acc.canonicalize().map_err(|e| e.to_string())?; + if !canon.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + acc = canon; + } + } + Component::ParentDir => { + acc.pop(); + if !acc.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + } + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) => { + return Err("invalid path".into()); + } + } + } + if acc.exists() { + let canon = acc.canonicalize().map_err(|e| e.to_string())?; + if !canon.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + return Ok(canon); + } + if !acc.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + Ok(acc) +} + +fn canonical_note_workspace_root(workspace_root: &str) -> Result { + let trimmed = workspace_root.trim(); + if trimmed.is_empty() { + return Err("empty workspace root".into()); + } + let p = PathBuf::from(trimmed); + let canon = p.canonicalize().map_err(|e| e.to_string())?; + if !canon.is_dir() { + return Err("workspace root is not a directory".into()); + } + Ok(canon) +} /// Lists file and subdirectory names under `relative_dir` (relative to process cwd). /// `relative_dir` が空なら cwd 直下を列挙する。 @@ -13,12 +84,54 @@ use std::path::PathBuf; pub fn list_workspace_directory_entries(relative_dir: String) -> Result, String> { let cwd = std::env::current_dir().map_err(|e| e.to_string())?; let cwd_canon = cwd.canonicalize().map_err(|e| e.to_string())?; - let target = resolve_under_cwd(&cwd_canon, &relative_dir)?; + let target = resolve_under_root(&cwd_canon, &relative_dir)?; if !target.is_dir() { return Ok(vec![]); } + list_directory_names(&target, DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES) +} + +/// Reads a UTF-8 text file under `workspace_root` (canonicalized); size-capped. +/// `workspace_root` 配下の UTF-8 テキストを読む(サイズ上限あり)。 +#[tauri::command] +pub fn read_note_workspace_file(workspace_root: String, relative_path: String) -> Result { + let root_canon = canonical_note_workspace_root(&workspace_root)?; + let target = resolve_under_root(&root_canon, &relative_path)?; + if !target.is_file() { + return Err("not a file".into()); + } + let meta = fs::metadata(&target).map_err(|e| e.to_string())?; + if meta.len() > MAX_NOTE_WORKSPACE_FILE_BYTES { + return Err("file too large".into()); + } + fs::read_to_string(&target).map_err(|e| e.to_string()) +} + +/// Lists names in `relative_dir` under `workspace_root` (same shape as {@link list_workspace_directory_entries}). +/// {@link list_workspace_directory_entries} と同じ形で `workspace_root` 配下を列挙する。 +#[tauri::command] +pub fn list_note_workspace_entries( + workspace_root: String, + relative_dir: String, + max_entries: Option, +) -> Result, String> { + let cap = max_entries + .unwrap_or(DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES) + .min(HARD_MAX_NOTE_WORKSPACE_ENTRIES); + let root_canon = canonical_note_workspace_root(&workspace_root)?; + let target = resolve_under_root(&root_canon, &relative_dir)?; + if !target.is_dir() { + return Ok(vec![]); + } + list_directory_names(&target, cap) +} + +fn list_directory_names(target: &Path, max_entries: u32) -> Result, String> { let mut out: Vec = Vec::new(); - for entry in std::fs::read_dir(&target).map_err(|e| e.to_string())? { + for entry in fs::read_dir(target).map_err(|e| e.to_string())? { + if out.len() >= max_entries as usize { + break; + } let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { @@ -35,45 +148,36 @@ pub fn list_workspace_directory_entries(relative_dir: String) -> Result Result { - let trimmed = relative_dir.trim(); - if trimmed.is_empty() { - return Ok(cwd_canon.clone()); - } - let mut acc = cwd_canon.clone(); - for comp in std::path::Path::new(trimmed).components() { - match comp { - std::path::Component::Normal(c) => { - acc.push(c); - if acc.exists() { - let canon = acc.canonicalize().map_err(|e| e.to_string())?; - if !canon.starts_with(cwd_canon) { - return Err("path outside workspace".into()); - } - acc = canon; - } - } - std::path::Component::ParentDir => { - acc.pop(); - if !acc.starts_with(cwd_canon) { - return Err("path outside workspace".into()); - } - } - std::path::Component::CurDir => {} - std::path::Component::RootDir | std::path::Component::Prefix(_) => { - return Err("invalid path".into()); - } - } - } - if acc.exists() { - let canon = acc.canonicalize().map_err(|e| e.to_string())?; - if !canon.starts_with(cwd_canon) { - return Err("path outside workspace".into()); - } - return Ok(canon); +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn resolve_under_root_rejects_parent_escape() { + let tmp = tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let err = resolve_under_root(&root, "..").unwrap_err(); + assert!(err.contains("outside") || err.contains("workspace")); } - if !acc.starts_with(cwd_canon) { - return Err("path outside workspace".into()); + + #[test] + fn read_note_workspace_file_reads_utf8() { + let tmp = tempdir().unwrap(); + let sub = tmp.path().join("proj"); + fs::create_dir(&sub).unwrap(); + let f = sub.join("hello.txt"); + let mut file = fs::File::create(&f).unwrap(); + writeln!(file, "hi").unwrap(); + drop(file); + + let root = sub.canonicalize().unwrap(); + let text = read_note_workspace_file( + root.to_string_lossy().to_string(), + "hello.txt".to_string(), + ) + .unwrap(); + assert!(text.contains("hi")); } - Ok(acc) } diff --git a/src/App.tsx b/src/App.tsx index f06f4aed..48cd55f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import { GlobalShortcutsProvider } from "./components/layout/GlobalShortcutsProv import { ProtectedRoute } from "./components/auth/ProtectedRoute"; import { AIChatProvider } from "./contexts/AIChatContext"; import { AIChatConversationsProvider } from "./hooks/useAIChatConversations"; +import { FilePreviewDialogHost } from "./components/note/FilePreviewDialogHost"; const queryClient = new QueryClient(); @@ -57,6 +58,7 @@ const App = () => ( {/* unstable_useTransitions を無効化: リンク後の表示が遅れる問題を防ぐ(RR v7 は future 廃止) Disable unstable_useTransitions: prevents display delay after link navigation (RR v7 removed future flags) */} + diff --git a/src/components/editor/TiptapEditor.tsx b/src/components/editor/TiptapEditor.tsx index 1e853bd9..e10e7c18 100644 --- a/src/components/editor/TiptapEditor.tsx +++ b/src/components/editor/TiptapEditor.tsx @@ -78,6 +78,7 @@ const TiptapEditor: React.FC = ({ slashAgentBusy, claudeAgentSlashAvailable, onSlashAgentBusyChange, + claudeWorkspaceRoot, } = useTiptapEditorController({ content, onChange, @@ -139,6 +140,7 @@ const TiptapEditor: React.FC = ({ onClose={handleSlashClose} claudeAgentSlashAvailable={claudeAgentSlashAvailable} onAgentBusyChange={onSlashAgentBusyChange} + claudeWorkspaceRoot={claudeWorkspaceRoot} /> )} {slashAgentBusy ? : null} diff --git a/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx b/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx index dbb4f8b3..6f290f63 100644 --- a/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx +++ b/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx @@ -21,6 +21,8 @@ interface SlashSuggestionLayerProps { claudeAgentSlashAvailable: boolean; /** Fires while Claude Code runs for an agent command. / エージェント実行中 */ onAgentBusyChange?: (busy: boolean) => void; + /** Note-linked workspace root (desktop). / ノート紐付けワークスペース */ + claudeWorkspaceRoot?: string | null; } /** @@ -35,6 +37,7 @@ export const SlashSuggestionLayer: React.FC = ({ onClose, claudeAgentSlashAvailable, onAgentBusyChange, + claudeWorkspaceRoot, }) => { if (!suggestionState?.active || !suggestionState.range || !position || !editor) return null; @@ -54,6 +57,7 @@ export const SlashSuggestionLayer: React.FC = ({ onClose={onClose} claudeAgentSlashAvailable={claudeAgentSlashAvailable} onAgentBusyChange={onAgentBusyChange} + claudeWorkspaceRoot={claudeWorkspaceRoot} /> ); diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts index 25d92452..024fb3ab 100644 --- a/src/components/editor/TiptapEditor/editorConfig.ts +++ b/src/components/editor/TiptapEditor/editorConfig.ts @@ -19,6 +19,7 @@ import { ExecutableCodeBlock } from "../extensions/ExecutableCodeBlockExtension" import { ImageUpload, type ImageUploadOptions } from "../extensions/ImageUploadExtension"; import { StorageImage, type StorageImageOptions } from "../extensions/StorageImageExtension"; import { WikiLink } from "../extensions/WikiLinkExtension"; +import { FileReference } from "../extensions/FileReferenceExtension"; import { Mermaid } from "../extensions/MermaidExtension"; import { WikiLinkSuggestionPlugin, @@ -102,6 +103,13 @@ export interface EditorExtensionsOptions { imageOptions: Partial; /** When set, enables Y.js collaboration and caret; StarterKit history is disabled */ collaboration?: CollaborationExtensionsOptions; + /** + * Optional note-linked workspace for `@file:` and Claude cwd (Issue #461). + * `@file:` と Claude cwd 用のノート紐付けワークスペース(任意、Issue #461)。 + */ + fileReference?: { + getWorkspaceRoot: () => string | null; + }; } /** @@ -187,6 +195,9 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens WikiLink.configure({ onLinkClick: options.onLinkClick, }), + FileReference.configure({ + getWorkspaceRoot: options.fileReference?.getWorkspaceRoot ?? (() => null), + }), WikiLinkSuggestionPlugin.configure({ onStateChange: options.onStateChange, }), diff --git a/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts b/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts index 6d0fbc58..f0aec4c5 100644 --- a/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts +++ b/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts @@ -12,4 +12,6 @@ export interface SlashSuggestionMenuProps { onClose: () => void; claudeAgentSlashAvailable: boolean; onAgentBusyChange?: (busy: boolean) => void; + /** Note-linked workspace for agent cwd + path completion (Issue #461). */ + claudeWorkspaceRoot?: string | null; } diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts index 4104f4d7..600db114 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + type MutableRefObject, + type RefObject, +} from "react"; import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/core"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; @@ -17,8 +24,8 @@ interface UseEditorSetupOptions { isReadOnly: boolean; onContentError: TiptapEditorProps["onContentError"]; collaborationConfig: TiptapEditorProps["collaborationConfig"]; - editorRef: React.MutableRefObject; - lastSelectionRef: React.MutableRefObject<{ from: number; to: number } | null>; + editorRef: MutableRefObject; + lastSelectionRef: MutableRefObject<{ from: number; to: number } | null>; handleLinkClick: (title: string) => void; handleStateChange: (state: WikiLinkSuggestionState) => void; handleSlashStateChange: (state: SlashSuggestionState) => void; @@ -30,11 +37,19 @@ interface UseEditorSetupOptions { handleCopyImageUrl: (src: string) => void; suggestionState: WikiLinkSuggestionState | null; slashState: SlashSuggestionState | null; - suggestionRef: React.RefObject; - slashRef: React.RefObject; + suggestionRef: RefObject; + slashRef: RefObject; + /** Note-linked workspace root for `@file:` (Issue #461). / `@file:` 用ワークスペースルート */ + workspaceRoot: string | null; } +/** + * + */ export function useEditorSetup(options: UseEditorSetupOptions) { + /** + * + */ const { content, onChange, @@ -59,11 +74,21 @@ export function useEditorSetup(options: UseEditorSetupOptions) { slashState, suggestionRef, slashRef, + workspaceRoot, } = options; + /** + * + */ const isEditorInitializedRef = useRef(false); + /** + * + */ const lastReportedContentRef = useRef(null); + /** + * + */ const initialParsedContent = useMemo(() => { if (!content) return undefined; try { @@ -86,17 +111,29 @@ export function useEditorSetup(options: UseEditorSetupOptions) { }); }, [content, initialParsedContent, onContentError]); + /** + * + */ const useCollaborationMode = Boolean( collaborationConfig?.xmlFragment && collaborationConfig?.user, ); + /** + * + */ const slashStateRef = useRef(slashState); + /** + * + */ const suggestionStateRef = useRef(suggestionState); useEffect(() => { slashStateRef.current = slashState; suggestionStateRef.current = suggestionState; }, [slashState, suggestionState]); + /** + * + */ const editor = useEditor( { extensions: createEditorExtensions({ @@ -116,6 +153,9 @@ export function useEditorSetup(options: UseEditorSetupOptions) { onCopyUrl: handleCopyImageUrl, getAuthenticatedImageUrl: async (url: string) => { try { + /** + * + */ const r = await fetch(url, { credentials: "include", }); @@ -135,6 +175,9 @@ export function useEditorSetup(options: UseEditorSetupOptions) { user: collaborationConfig.user, } : undefined, + fileReference: { + getWorkspaceRoot: () => workspaceRoot, + }, }), content: useCollaborationMode ? undefined : initialParsedContent, autofocus: autoFocus ? "end" : false, @@ -161,6 +204,9 @@ export function useEditorSetup(options: UseEditorSetupOptions) { if (initialParsedContent) isEditorInitializedRef.current = true; }, onSelectionUpdate: ({ editor }) => { + /** + * + */ const { from, to } = editor.state.selection; lastSelectionRef.current = { from, to }; if (useCollaborationMode && collaborationConfig?.awareness) { @@ -169,13 +215,16 @@ export function useEditorSetup(options: UseEditorSetupOptions) { } }, }, - [pageId, useCollaborationMode], + [pageId, useCollaborationMode, workspaceRoot], ); useEffect(() => { editorRef.current = editor; }, [editor, editorRef]); + /** + * + */ const handleInsertMermaid = useCallback( (code: string) => { if (!editor) return; diff --git a/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts index 51a06d2d..c1bb201b 100644 --- a/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts +++ b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts @@ -68,7 +68,15 @@ export function useSlashSuggestionMenu( props: SlashSuggestionMenuProps, ref: Ref, ): UseSlashSuggestionMenuResult { - const { editor, query, range, onClose, claudeAgentSlashAvailable, onAgentBusyChange } = props; + const { + editor, + query, + range, + onClose, + claudeAgentSlashAvailable, + onAgentBusyChange, + claudeWorkspaceRoot, + } = props; const { t } = useTranslation(); const { toast } = useToast(); const [selectedIndex, setSelectedIndex] = useState(0); @@ -82,6 +90,7 @@ export function useSlashSuggestionMenu( editor, t, claudeAgentSlashAvailable, + claudeWorkspaceRoot ?? null, ); useEffect(() => { @@ -114,6 +123,7 @@ export function useSlashSuggestionMenu( query, editor, range, + claudeCwd: claudeWorkspaceRoot ?? undefined, }); if (err) { toast({ @@ -126,7 +136,7 @@ export function useSlashSuggestionMenu( onAgentBusyChange?.(false); } }, - [onClose, onAgentBusyChange, query, editor, range, toast, t], + [onClose, onAgentBusyChange, query, editor, range, toast, t, claudeWorkspaceRoot], ); const selectItem = useCallback( diff --git a/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts b/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts index d10dccba..07ae0623 100644 --- a/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts +++ b/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts @@ -21,6 +21,8 @@ export function useSlashSuggestionMenuData( editor: Editor, t: (key: string) => string, claudeAgentSlashAvailable: boolean, + /** When set, path completion lists this root instead of process cwd (Issue #461). */ + noteWorkspaceRoot: string | null, ): { items: ReturnType; pathCompletionEnabled: boolean; @@ -36,7 +38,11 @@ export function useSlashSuggestionMenuData( const pathCompletionEnabled = claudeAgentSlashAvailable && shouldOfferPathCompletion(query) && pathMatch !== null; const pathArgs = pathMatch?.args ?? ""; - const pathSuggestions = useWorkspacePathCompletions(pathArgs, pathCompletionEnabled); + const pathSuggestions = useWorkspacePathCompletions( + pathArgs, + pathCompletionEnabled, + noteWorkspaceRoot, + ); return { items, pathCompletionEnabled, pathArgs, pathSuggestions }; } diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index c3a95b43..5be6f611 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useRef, useState, type MutableRefObject } from "react"; import type { MutableRefObject, RefObject } from "react"; import type { Editor } from "@tiptap/core"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; @@ -14,6 +14,7 @@ import { useTiptapEditorStorageFeatures, useThumbnailController } from "./useTip import { useSuggestionControllers } from "./useSuggestionControllers"; import { useImageUploadController } from "./useImageUploadController"; import { useClaudeAgentSlashAvailability } from "./useClaudeAgentSlashAvailability"; +import { useNoteWorkspaceOptional } from "@/contexts/NoteWorkspaceContext"; import type { TiptapEditorProps } from "./types"; function useEditorControllers(args: { @@ -51,6 +52,8 @@ function useEditorControllers(args: { slashRef: RefObject; handleInsertImageClick: () => void; handleImageUpload: (files: File[]) => Promise; + /** Note-linked workspace root for `@file:` (Issue #461). */ + workspaceRoot: string | null; }) { const { editor, handleInsertMermaid, isEditorInitializedRef } = useEditorSetup({ content: args.content, @@ -76,6 +79,7 @@ function useEditorControllers(args: { slashState: args.slashState, suggestionRef: args.suggestionRef, slashRef: args.slashRef, + workspaceRoot: args.workspaceRoot, }); const suggestionUi = useSuggestionEffects({ @@ -132,6 +136,8 @@ export function useTiptapEditorController({ onWikiContentApplied, }: TiptapEditorProps) { const { editorFontSizePx } = useGeneralSettings(); + const noteWorkspace = useNoteWorkspaceOptional(); + const workspaceRoot = noteWorkspace?.workspaceRoot ?? null; const editorContainerRef = useRef(null); const editorRef = useRef(null); const lastSelectionRef = useRef<{ from: number; to: number } | null>(null); @@ -206,6 +212,7 @@ export function useTiptapEditorController({ slashRef: suggestionControllers.slashRef, handleInsertImageClick: imageUpload.handleInsertImageClick, handleImageUpload: imageUpload.handleImageUpload, + workspaceRoot, }); const { handleInsertThumbnailImage } = useThumbnailController( editorRef, @@ -247,5 +254,6 @@ export function useTiptapEditorController({ slashAgentBusy, claudeAgentSlashAvailable, onSlashAgentBusyChange: setSlashAgentBusy, + claudeWorkspaceRoot: noteWorkspace?.workspaceRoot ?? null, }; } diff --git a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts index 38117a1e..769da8a6 100644 --- a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts +++ b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts @@ -4,6 +4,7 @@ */ import { useEffect, useState } from "react"; +import { listNoteWorkspaceEntries } from "@/lib/noteWorkspace/noteWorkspaceIo"; import { listWorkspaceDirectoryEntries } from "@/lib/workspace/bridge"; /** @@ -31,7 +32,12 @@ export function parsePathCompletionArgs(args: string): { dir: string; filePrefix * Loads directory entry names for slash path completion (max 40). * スラッシュのパス補完用にディレクトリエントリを読む(最大 40 件)。 */ -export function useWorkspacePathCompletions(args: string, enabled: boolean): string[] { +export function useWorkspacePathCompletions( + args: string, + enabled: boolean, + /** When set, list under this root (Issue #461). Else process cwd. */ + noteWorkspaceRoot: string | null, +): string[] { const [items, setItems] = useState([]); useEffect(() => { @@ -42,7 +48,10 @@ export function useWorkspacePathCompletions(args: string, enabled: boolean): str const { dir, filePrefix } = parsePathCompletionArgs(args); let cancelled = false; const t = window.setTimeout(() => { - void listWorkspaceDirectoryEntries(dir) + const promise = noteWorkspaceRoot + ? listNoteWorkspaceEntries(noteWorkspaceRoot, dir) + : listWorkspaceDirectoryEntries(dir); + void promise .then((names) => { if (cancelled) return; const fp = filePrefix.toLowerCase(); @@ -57,7 +66,7 @@ export function useWorkspacePathCompletions(args: string, enabled: boolean): str cancelled = true; window.clearTimeout(t); }; - }, [args, enabled]); + }, [args, enabled, noteWorkspaceRoot]); return items; } diff --git a/src/components/editor/executableCodeBlock/useExecutableCodeBlockController.ts b/src/components/editor/executableCodeBlock/useExecutableCodeBlockController.ts index 24c380a0..3a960093 100644 --- a/src/components/editor/executableCodeBlock/useExecutableCodeBlockController.ts +++ b/src/components/editor/executableCodeBlock/useExecutableCodeBlockController.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import type { NodeViewProps } from "@tiptap/react"; import { toast } from "@zedi/ui/components/sonner"; +import { useNoteWorkspaceOptional } from "@/contexts/NoteWorkspaceContext"; import { loadGeneralSettings } from "@/lib/generalSettings"; import { interpretExecutableCodeOutput, @@ -39,6 +40,8 @@ export function useExecutableCodeBlockController({ }: UseExecutableCodeBlockControllerParams) { const [confirmOpen, setConfirmOpen] = useState(false); const [interpretLoading, setInterpretLoading] = useState(false); + const noteWorkspace = useNoteWorkspaceOptional(); + const claudeCwd = noteWorkspace?.workspaceRoot ?? undefined; const runImpl = useCallback(async () => { updateAttributes({ @@ -50,7 +53,9 @@ export function useExecutableCodeBlockController({ durationMs: null, }); const started = performance.now(); - const result = await runExecutableCodeInNotebook(language, codeText); + const result = await runExecutableCodeInNotebook(language, codeText, undefined, { + cwd: claudeCwd, + }); const durationMsNext = Math.round(performance.now() - started); if (!result.ok) { @@ -70,7 +75,7 @@ export function useExecutableCodeBlockController({ durationMs: durationMsNext, errorMessage: "", }); - }, [codeText, language, updateAttributes]); + }, [codeText, language, updateAttributes, claudeCwd]); const handleRunClick = useCallback(() => { if (claudeAvailable !== true || runStatus === "running") return; diff --git a/src/components/editor/extensions/FileReferenceExtension.ts b/src/components/editor/extensions/FileReferenceExtension.ts new file mode 100644 index 00000000..87e61b1e --- /dev/null +++ b/src/components/editor/extensions/FileReferenceExtension.ts @@ -0,0 +1,145 @@ +/** + * `@file:` mark: typed path, click previews via Tauri `read_note_workspace_file` (Issue #461). + * `@file:` マーク。クリックで Tauri `read_note_workspace_file` によりプレビュー(Issue #461)。 + */ +import { Mark, mergeAttributes, markInputRule } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { dispatchFilePreview } from "@/lib/noteWorkspace/filePreviewEvents"; +import { readNoteWorkspaceFile } from "@/lib/noteWorkspace/noteWorkspaceIo"; + +/** Max characters shown in the preview dialog (UX; Rust already caps file size). / プレビューダイアログの最大文字数 */ +const FILE_PREVIEW_DISPLAY_MAX_CHARS = 32_000; + +/** + * Options for {@link FileReference}. / {@link FileReference} のオプション。 + */ +export interface FileReferenceOptions { + HTMLAttributes: Record; + /** Returns linked workspace root for the current note (desktop). / ノートのワークスペースルート */ + getWorkspaceRoot: () => string | null; +} + +declare module "@tiptap/core" { + interface Commands { + fileReference: { + setFileReference: (attrs: { path: string }) => ReturnType; + unsetFileReference: () => ReturnType; + }; + } +} + +/** + * Marks `@file:relative/path` segments; click loads preview via Tauri (Issue #461). + * `@file:相対パス` をマークし、クリックで Tauri 経由プレビュー(Issue #461)。 + */ +export const FileReference = Mark.create({ + name: "fileReference", + + priority: 1000, + + addOptions() { + return { + HTMLAttributes: {}, + getWorkspaceRoot: () => null as string | null, + }; + }, + + addAttributes() { + return { + path: { + default: "", + parseHTML: (element) => element.getAttribute("data-path") ?? "", + renderHTML: (attributes) => ({ + "data-path": attributes.path, + }), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-file-ref]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-file-ref": "", + class: + "file-reference rounded bg-muted/80 px-0.5 font-mono text-sm text-foreground underline decoration-dotted", + }), + 0, + ]; + }, + + addCommands() { + return { + setFileReference: + (attrs) => + ({ commands }) => + commands.setMark(this.name, attrs), + unsetFileReference: + () => + ({ commands }) => + commands.unsetMark(this.name), + }; + }, + + addInputRules() { + return [ + markInputRule({ + find: /(?:^|\s)(@file:[^\s]+)\s$/, + type: this.type, + getAttributes: (match) => { + const full = match[1] ?? ""; + const path = full.startsWith("@file:") ? full.slice(6) : full; + return { path }; + }, + }), + ]; + }, + + addProseMirrorPlugins() { + const getRoot = this.options.getWorkspaceRoot; + return [ + new Plugin({ + key: new PluginKey("fileReferenceClick"), + props: { + handleClick: (view, _pos, event) => { + const target = event.target as HTMLElement | null; + const el = target?.closest?.("[data-file-ref]") as HTMLElement | null; + if (!el) return false; + const path = el.getAttribute("data-path"); + if (!path) return false; + const root = getRoot(); + if (!root) { + event.preventDefault(); + event.stopPropagation(); + dispatchFilePreview({ relativePath: path, noWorkspace: true }); + return true; + } + event.preventDefault(); + event.stopPropagation(); + void (async () => { + const result = await readNoteWorkspaceFile(root, path); + if (result.ok) { + const truncated = result.content.length > FILE_PREVIEW_DISPLAY_MAX_CHARS; + const content = truncated + ? result.content.slice(0, FILE_PREVIEW_DISPLAY_MAX_CHARS) + : result.content; + dispatchFilePreview({ + relativePath: path, + content, + truncated, + }); + } else { + dispatchFilePreview({ relativePath: path, error: result.error }); + } + })(); + return true; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/note/FilePreviewDialogHost.tsx b/src/components/note/FilePreviewDialogHost.tsx new file mode 100644 index 00000000..0829939c --- /dev/null +++ b/src/components/note/FilePreviewDialogHost.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@zedi/ui"; +import { useTranslation } from "react-i18next"; +import { + FILE_PREVIEW_EVENT, + type FilePreviewEventDetail, +} from "@/lib/noteWorkspace/filePreviewEvents"; + +/** + * Global listener for {@link FILE_PREVIEW_EVENT}; shows scrollable preview (Issue #461). + * {@link FILE_PREVIEW_EVENT} を購読し、スクロール可能なプレビューを表示(Issue #461)。 + */ +export function FilePreviewDialogHost(): React.ReactElement { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [detail, setDetail] = useState(null); + + const onEvent = useCallback((ev: Event) => { + const ce = ev as CustomEvent; + setDetail(ce.detail ?? null); + setOpen(true); + }, []); + + useEffect(() => { + window.addEventListener(FILE_PREVIEW_EVENT, onEvent); + return () => window.removeEventListener(FILE_PREVIEW_EVENT, onEvent); + }, [onEvent]); + + const close = useCallback(() => { + setOpen(false); + setDetail(null); + }, []); + + return ( + !o && close()}> + + + + {detail?.relativePath ?? ""} + + + {detail?.noWorkspace ? ( +

{t("editor.filePreview.noWorkspace")}

+ ) : detail?.error ? ( +

{detail.error}

+ ) : ( + <> + {detail?.truncated ? ( +

{t("editor.filePreview.truncated")}

+ ) : null} +
+              {detail?.content ?? ""}
+            
+ + )} + + + +
+
+ ); +} diff --git a/src/components/note/NoteWorkspaceToolbar.tsx b/src/components/note/NoteWorkspaceToolbar.tsx new file mode 100644 index 00000000..cc84bee7 --- /dev/null +++ b/src/components/note/NoteWorkspaceToolbar.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useState } from "react"; +import { FolderOpen, FolderTree, Trash2 } from "lucide-react"; +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@zedi/ui"; +import { useTranslation } from "react-i18next"; +import { isTauriDesktop } from "@/lib/platform"; +import { listNoteWorkspaceEntries } from "@/lib/noteWorkspace/noteWorkspaceIo"; +import { useNoteWorkspaceOptional } from "@/contexts/NoteWorkspaceContext"; + +/** + * Note-linked local folder + shallow file tree (desktop, Issue #461). + * ノートに紐づくローカルフォルダと浅いファイルツリー(デスクトップ、Issue #461)。 + */ +export function NoteWorkspaceToolbar() { + const { t } = useTranslation(); + const ctx = useNoteWorkspaceOptional(); + const [treeOpen, setTreeOpen] = useState(false); + const [entries, setEntries] = useState([]); + const [relDir, setRelDir] = useState(""); + + const root = ctx?.workspaceRoot ?? null; + + const fetchEntries = useCallback( + async (dir: string) => { + if (!root) { + setEntries([]); + return; + } + const list = await listNoteWorkspaceEntries(root, dir); + setEntries(list); + }, + [root], + ); + + const openTree = useCallback(() => { + setRelDir(""); + setTreeOpen(true); + void fetchEntries(""); + }, [fetchEntries]); + + if (!ctx || !isTauriDesktop()) return null; + + const displayPath = root ? (root.length > 48 ? `…${root.slice(-44)}` : root) : ""; + + return ( + <> +
+ {t("editor.noteWorkspace.label")} + {root ? ( + <> + + {displayPath} + + + + + + ) : ( + + )} +
+ + { + setTreeOpen(open); + if (!open) setRelDir(""); + }} + > + + + {t("editor.noteWorkspace.treeTitle")} + +
+ {relDir ? `${relDir}/` : "/"} +
+
+ {relDir ? ( + + ) : null} +
    + {entries.map((name) => ( +
  • + +
  • + ))} +
+ {entries.length === 0 ? ( +

{t("editor.noteWorkspace.treeEmpty")}

+ ) : null} +
+
+
+ + ); +} diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx new file mode 100644 index 00000000..953e4243 --- /dev/null +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -0,0 +1,97 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + clearNoteWorkspacePath, + readNoteWorkspacePath, + writeNoteWorkspacePath, +} from "@/lib/noteWorkspace/noteWorkspaceStore"; +import { pickNoteWorkspaceDirectory } from "@/lib/noteWorkspace/pickNoteWorkspaceDirectory"; + +/** + * Value provided by {@link NoteWorkspaceProvider} (local workspace path, Issue #461). + * {@link NoteWorkspaceProvider} が提供する値(ローカルワークスペース、Issue #461)。 + */ +export interface NoteWorkspaceContextValue { + /** Current note id / 現在のノート ID */ + noteId: string; + /** + * Canonical workspace root from local storage, or null. + * ローカルストレージのワークスペースルート。未設定は null。 + */ + workspaceRoot: string | null; + /** Persists path and updates state / パスを保存して状態更新 */ + setWorkspaceRoot: (path: string) => void; + /** Clears persisted path / 保存を消去 */ + clearWorkspace: () => void; + /** Opens folder picker; on success persists / フォルダ選択して保存 */ + pickWorkspace: () => Promise; +} + +const NoteWorkspaceContext = createContext(null); + +/** + * Provides per-note linked workspace path (local only, Issue #461). + * ノート単位のリンク済みワークスペース(ローカルのみ、Issue #461)。 + */ +export function NoteWorkspaceProvider({ + noteId, + children, +}: { + noteId: string; + children: ReactNode; +}) { + const [workspaceRoot, setWorkspaceRootState] = useState(() => + readNoteWorkspacePath(noteId), + ); + + useEffect(() => { + setWorkspaceRootState(readNoteWorkspacePath(noteId)); + }, [noteId]); + + const setWorkspaceRoot = useCallback( + (path: string) => { + writeNoteWorkspacePath(noteId, path); + setWorkspaceRootState(path.trim()); + }, + [noteId], + ); + + const clearWorkspace = useCallback(() => { + clearNoteWorkspacePath(noteId); + setWorkspaceRootState(null); + }, [noteId]); + + const pickWorkspace = useCallback(async () => { + const path = await pickNoteWorkspaceDirectory(); + if (path) setWorkspaceRoot(path); + }, [setWorkspaceRoot]); + + const value = useMemo( + () => ({ + noteId, + workspaceRoot, + setWorkspaceRoot, + clearWorkspace, + pickWorkspace, + }), + [noteId, workspaceRoot, setWorkspaceRoot, clearWorkspace, pickWorkspace], + ); + + return {children}; +} + +/** + * Optional hook: null when outside {@link NoteWorkspaceProvider}. + * {@link NoteWorkspaceProvider} 外では null。 + */ +// eslint-disable-next-line react-refresh/only-export-components -- hook is paired with Provider in this module +export function useNoteWorkspaceOptional(): NoteWorkspaceContextValue | null { + return useContext(NoteWorkspaceContext); +} diff --git a/src/hooks/useAIChatExecute.ts b/src/hooks/useAIChatExecute.ts index d99321ed..6938e535 100644 --- a/src/hooks/useAIChatExecute.ts +++ b/src/hooks/useAIChatExecute.ts @@ -155,7 +155,13 @@ export async function executeSendMessage(params: ExecuteSendMessageParams): Prom provider: effectiveSettings.provider, model: effectiveSettings.model, messages: apiMessages, - options: { stream: true, feature: "chat" }, + options: { + stream: true, + feature: "chat", + ...(effectiveSettings.provider === "claude-code" && context?.claudeWorkspaceRoot + ? { cwd: context.claudeWorkspaceRoot } + : {}), + }, }; await streamAssistantCompletion(effectiveSettings, request, abortControllerRef.current.signal, { diff --git a/src/hooks/useAIChatExecuteRegenerate.ts b/src/hooks/useAIChatExecuteRegenerate.ts index cf06c051..b5759708 100644 --- a/src/hooks/useAIChatExecuteRegenerate.ts +++ b/src/hooks/useAIChatExecuteRegenerate.ts @@ -119,7 +119,13 @@ export async function executeRegenerateAssistant( provider: effectiveSettings.provider, model: effectiveSettings.model, messages: apiMessages, - options: { stream: true, feature: "chat" }, + options: { + stream: true, + feature: "chat", + ...(effectiveSettings.provider === "claude-code" && context?.claudeWorkspaceRoot + ? { cwd: context.claudeWorkspaceRoot } + : {}), + }, }; await streamAssistantCompletion(effectiveSettings, request, abortControllerRef.current.signal, { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index db456778..0f640cd6 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -3,6 +3,20 @@ "slashMenuAriaLabel": "Slash command menu", "slashNoResults": "No matching commands", "slashAgentRunning": "Running Claude Code…", + "noteWorkspace": { + "label": "Workspace", + "linkFolder": "Link folder", + "change": "Change", + "browse": "Browse files", + "clear": "Clear workspace link", + "treeTitle": "Workspace files", + "treeEmpty": "This folder is empty." + }, + "filePreview": { + "truncated": "Preview shows the first 32,000 characters only.", + "close": "Close", + "noWorkspace": "Link a workspace folder for this note (toolbar) to preview files." + }, "slashAgent": { "errorTitle": "Agent command failed" }, diff --git a/src/i18n/locales/ja/editor.json b/src/i18n/locales/ja/editor.json index 8d51aa78..66630244 100644 --- a/src/i18n/locales/ja/editor.json +++ b/src/i18n/locales/ja/editor.json @@ -3,6 +3,20 @@ "slashMenuAriaLabel": "スラッシュコマンドメニュー", "slashNoResults": "一致する項目がありません", "slashAgentRunning": "Claude Code を実行中…", + "noteWorkspace": { + "label": "ワークスペース", + "linkFolder": "フォルダをリンク", + "change": "変更", + "browse": "ファイルを参照", + "clear": "リンクを解除", + "treeTitle": "ワークスペース内のファイル", + "treeEmpty": "このフォルダは空です。" + }, + "filePreview": { + "truncated": "先頭 32,000 文字のみ表示します。", + "close": "閉じる", + "noWorkspace": "ファイルをプレビューするには、ツールバーからこのノート用のワークスペースフォルダをリンクしてください。" + }, "slashAgent": { "errorTitle": "エージェントコマンドに失敗しました" }, diff --git a/src/lib/agentSlashCommands/executeAgentSlashCommand.ts b/src/lib/agentSlashCommands/executeAgentSlashCommand.ts index d0915ac0..777aa5e6 100644 --- a/src/lib/agentSlashCommands/executeAgentSlashCommand.ts +++ b/src/lib/agentSlashCommands/executeAgentSlashCommand.ts @@ -31,8 +31,10 @@ export async function executeAgentSlashCommand(options: { editor: Editor; range: { from: number; to: number }; signal?: AbortSignal; + /** Claude Code working directory (note-linked workspace, Issue #461). */ + claudeCwd?: string; }): Promise { - const { commandId, query, editor, range, signal } = options; + const { commandId, query, editor, range, signal, claudeCwd } = options; const meta = AGENT_SLASH_PREFIXES.find((p) => p.id === commandId); const prefix = meta?.prefix ?? ""; @@ -71,7 +73,10 @@ export async function executeAgentSlashCommand(options: { editor.chain().focus().deleteRange(range).run(); const prompt = buildAgentSlashPrompt(commandId, args, editor, { selectionText, plainText }); - const claudeOpts = getAgentSlashClaudeOptions(commandId); + const claudeOpts = { + ...getAgentSlashClaudeOptions(commandId), + ...(claudeCwd ? { cwd: claudeCwd } : {}), + }; const result = await runClaudeQueryToCompletion(prompt, claudeOpts, signal); if (!result.ok) { diff --git a/src/lib/aiService.ts b/src/lib/aiService.ts index 16a4e9ad..a5321b2c 100644 --- a/src/lib/aiService.ts +++ b/src/lib/aiService.ts @@ -29,6 +29,8 @@ export interface AIServiceRequest { webSearchOptions?: { search_context_size: "medium" | "low" | "high" }; useWebSearch?: boolean; useGoogleSearch?: boolean; + /** Claude Code sidecar cwd (note-linked workspace, desktop only). */ + cwd?: string; }; } @@ -178,6 +180,7 @@ async function callAIWithClaudeCode( maxTokens: request.options?.maxTokens, temperature: request.options?.temperature, stream: request.options?.stream, + cwd: request.options?.cwd, }, }, abortSignal, diff --git a/src/lib/executableCode/executeExecutableCode.ts b/src/lib/executableCode/executeExecutableCode.ts index bbc0fb26..f758eb64 100644 --- a/src/lib/executableCode/executeExecutableCode.ts +++ b/src/lib/executableCode/executeExecutableCode.ts @@ -44,6 +44,7 @@ export async function runExecutableCodeInNotebook( language: string, code: string, signal?: AbortSignal, + options?: { cwd?: string }, ): Promise { const prompt = buildExecutableCodeRunPrompt(language, code); const completion = await runClaudeQueryToCompletion( @@ -51,6 +52,7 @@ export async function runExecutableCodeInNotebook( { maxTurns: 12, allowedTools: ["Bash"], + ...(options?.cwd ? { cwd: options.cwd } : {}), }, signal, ); diff --git a/src/lib/noteWorkspace/filePreviewEvents.test.ts b/src/lib/noteWorkspace/filePreviewEvents.test.ts new file mode 100644 index 00000000..fd4bbc23 --- /dev/null +++ b/src/lib/noteWorkspace/filePreviewEvents.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dispatchFilePreview, FILE_PREVIEW_EVENT } from "./filePreviewEvents"; + +describe("filePreviewEvents", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("dispatchFilePreview emits CustomEvent with detail", () => { + const spy = vi.fn(); + window.addEventListener(FILE_PREVIEW_EVENT, spy); + dispatchFilePreview({ relativePath: "a/b.ts", content: "hi", truncated: false }); + expect(spy).toHaveBeenCalledTimes(1); + const ev = spy.mock.calls[0][0] as CustomEvent; + expect(ev.detail).toEqual({ relativePath: "a/b.ts", content: "hi", truncated: false }); + window.removeEventListener(FILE_PREVIEW_EVENT, spy); + }); +}); diff --git a/src/lib/noteWorkspace/filePreviewEvents.ts b/src/lib/noteWorkspace/filePreviewEvents.ts new file mode 100644 index 00000000..7bba408f --- /dev/null +++ b/src/lib/noteWorkspace/filePreviewEvents.ts @@ -0,0 +1,30 @@ +/** + * Dispatches workspace file preview UI (dialog host listens on `window`). + * ワークスペースファイルプレビュー用イベント(ダイアログホストが `window` で購読)。 + */ + +/** CustomEvent name for file preview. / ファイルプレビュー用イベント名 */ +export const FILE_PREVIEW_EVENT = "zedi:file-preview"; + +/** Payload for {@link FILE_PREVIEW_EVENT}. / {@link FILE_PREVIEW_EVENT} のペイロード */ +export interface FilePreviewEventDetail { + /** Relative path within the linked workspace. / リンク済みワークスペース内の相対パス */ + relativePath: string; + /** Set when read failed. / 読み取り失敗時 */ + error?: string; + /** True when no folder is linked for the note. / ノートにフォルダ未リンクのとき */ + noWorkspace?: boolean; + /** UTF-8 content when read succeeded (may be pre-truncated). / 成功時の本文(事前省略可) */ + content?: string; + /** True when `content` was truncated for display. / 表示用に省略したとき true */ + truncated?: boolean; +} + +/** + * Opens the file preview dialog by dispatching {@link FILE_PREVIEW_EVENT}. + * {@link FILE_PREVIEW_EVENT} を発火してプレビューダイアログを開く。 + */ +export function dispatchFilePreview(detail: FilePreviewEventDetail): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(FILE_PREVIEW_EVENT, { detail })); +} diff --git a/src/lib/noteWorkspace/noteWorkspaceIo.ts b/src/lib/noteWorkspace/noteWorkspaceIo.ts new file mode 100644 index 00000000..f40df269 --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceIo.ts @@ -0,0 +1,51 @@ +/** + * Tauri invoke wrappers for note-linked workspace file access (Issue #461). + * ノート紐付けワークスペースのファイルアクセス用 Tauri invoke(Issue #461)。 + */ + +import { invoke } from "@tauri-apps/api/core"; +import { isTauriDesktop } from "@/lib/platform"; + +/** + * Reads a UTF-8 file under workspace root (server-validated path). + * ワークスペースルート配下の UTF-8 ファイルを読む(サーバ側でパス検証)。 + */ +export async function readNoteWorkspaceFile( + workspaceRoot: string, + relativePath: string, +): Promise<{ ok: true; content: string } | { ok: false; error: string }> { + if (!isTauriDesktop()) { + return { ok: false, error: "Desktop only." }; + } + try { + const content = await invoke("read_note_workspace_file", { + workspaceRoot, + relativePath: relativePath.replace(/\\/g, "/"), + }); + return { ok: true, content }; + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + return { ok: false, error }; + } +} + +/** + * Lists directory entries (same shape as process-cwd listing). + * ディレクトリエントリを列挙する(プロセス cwd 列挙と同じ形)。 + */ +export async function listNoteWorkspaceEntries( + workspaceRoot: string, + relativeDir: string, + maxEntries?: number, +): Promise { + if (!isTauriDesktop()) return []; + try { + return await invoke("list_note_workspace_entries", { + workspaceRoot, + relativeDir: relativeDir.replace(/\\/g, "/"), + maxEntries: maxEntries ?? null, + }); + } catch { + return []; + } +} diff --git a/src/lib/noteWorkspace/noteWorkspaceStore.test.ts b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts new file mode 100644 index 00000000..1604a71e --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearNoteWorkspacePath, + readNoteWorkspacePath, + writeNoteWorkspacePath, +} from "./noteWorkspaceStore"; + +describe("noteWorkspaceStore", () => { + beforeEach(() => { + localStorage.removeItem("zedi.noteWorkspace.v1"); + }); + + it("reads and writes path per note", () => { + expect(readNoteWorkspacePath("n1")).toBe(null); + writeNoteWorkspacePath("n1", "/tmp/proj"); + expect(readNoteWorkspacePath("n1")).toBe("/tmp/proj"); + clearNoteWorkspacePath("n1"); + expect(readNoteWorkspacePath("n1")).toBe(null); + }); +}); diff --git a/src/lib/noteWorkspace/noteWorkspaceStore.ts b/src/lib/noteWorkspace/noteWorkspaceStore.ts new file mode 100644 index 00000000..c172f774 --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceStore.ts @@ -0,0 +1,65 @@ +/** + * Local-only persisted mapping noteId → absolute workspace path (Issue #461). + * ノート ID → ワークスペース絶対パスのローカル専用永続化(Issue #461)。 + * + * @remarks + * Not synced to the API server. Paths stay on this device. + * API サーバには同期しない。パスは端末ローカルのみ。 + */ + +const STORAGE_KEY = "zedi.noteWorkspace.v1"; + +type NoteWorkspaceMap = Record; + +function readMap(): NoteWorkspaceMap { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + return parsed as NoteWorkspaceMap; + } catch { + return {}; + } +} + +function writeMap(map: NoteWorkspaceMap): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + /* quota / private mode */ + } +} + +/** + * Returns the saved workspace path for a note, or null. + * ノートに保存されたワークスペースパスを返す。なければ null。 + */ +export function readNoteWorkspacePath(noteId: string): string | null { + const v = readMap()[noteId]; + return typeof v === "string" && v.trim().length > 0 ? v.trim() : null; +} + +/** + * Persists the workspace path for a note. + * ノートのワークスペースパスを保存する。 + */ +export function writeNoteWorkspacePath(noteId: string, absolutePath: string): void { + const map = readMap(); + map[noteId] = absolutePath.trim(); + writeMap(map); +} + +/** + * Removes the workspace path for a note. + * ノートのワークスペースパスを削除する。 + */ +export function clearNoteWorkspacePath(noteId: string): void { + const map = readMap(); + if (!(noteId in map)) return; + const { [noteId]: _removed, ...rest } = map; + void _removed; + writeMap(rest); +} diff --git a/src/lib/noteWorkspace/pickNoteWorkspaceDirectory.ts b/src/lib/noteWorkspace/pickNoteWorkspaceDirectory.ts new file mode 100644 index 00000000..bb03e120 --- /dev/null +++ b/src/lib/noteWorkspace/pickNoteWorkspaceDirectory.ts @@ -0,0 +1,27 @@ +/** + * Opens a native folder picker and returns the selected path (desktop only). + * ネイティブのフォルダ選択を開き、選ばれたパスを返す(デスクトップのみ)。 + */ + +import { open } from "@tauri-apps/plugin-dialog"; +import { isTauriDesktop } from "@/lib/platform"; + +/** + * Shows directory picker; returns canonical path string or null if cancelled. + * ディレクトリピッカーを表示し、正規化パスまたはキャンセル時 null。 + */ +export async function pickNoteWorkspaceDirectory(): Promise { + if (!isTauriDesktop()) return null; + try { + const selected = await open({ + directory: true, + multiple: false, + title: "Select project folder", + }); + if (selected === null) return null; + const path = Array.isArray(selected) ? selected[0] : selected; + return typeof path === "string" && path.length > 0 ? path : null; + } catch { + return null; + } +} diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index 19cd5a28..abb3ae0f 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -9,7 +9,9 @@ import { useNote, useNotePage } from "@/hooks/useNoteQueries"; import { useAuth } from "@/hooks/useAuth"; import { useCollaboration } from "@/hooks/useCollaboration"; import { ContentWithAIChat } from "@/components/ai-chat/ContentWithAIChat"; +import { NoteWorkspaceProvider, useNoteWorkspaceOptional } from "@/contexts/NoteWorkspaceContext"; import { useAIChatContext } from "@/contexts/AIChatContext"; +import { NoteWorkspaceToolbar } from "@/components/note/NoteWorkspaceToolbar"; import { convertMarkdownToTiptapContent } from "@/lib/markdownToTiptap"; import type { UseCollaborationReturn } from "@/lib/collaboration/types"; import type { Page } from "@/types/page"; @@ -24,29 +26,38 @@ function canEditPage( return Boolean(userId && page?.ownerUserId && page.ownerUserId === userId); } -/** key={page.id} でページ切替時にリセット。editorContent の初期値を page.content から取得。 */ +/** + * Uses `key` on the parent so page switches reset local editor state. + * `editorContent` の初期値は `page.content` から。 + */ function NotePageEditorEditable({ page, + noteId, collaboration, isCollaborationEnabled, }: { page: Page; + noteId: string; collaboration: UseCollaborationReturn; isCollaborationEnabled: boolean; }) { const [editorContent, setEditorContent] = useState(page.content ?? ""); const { setPageContext, contentAppendHandlerRef, insertAtCursorRef } = useAIChatContext(); + const noteWorkspace = useNoteWorkspaceOptional(); + const workspaceRoot = noteWorkspace?.workspaceRoot ?? null; const editorInsertRef = useRef<((content: unknown) => boolean) | null>(null); useEffect(() => { setPageContext({ type: "editor", pageId: page.id, + noteId, + claudeWorkspaceRoot: workspaceRoot ?? undefined, pageTitle: page.title, pageContent: editorContent.slice(0, 3000), pageFullContent: editorContent, }); - }, [page.id, page.title, editorContent, setPageContext]); + }, [page.id, page.title, editorContent, setPageContext, noteId, workspaceRoot]); useEffect(() => { return () => setPageContext(null); @@ -77,6 +88,7 @@ function NotePageEditorEditable({ return ( + { const { noteId, pageId } = useParams<{ noteId: string; pageId: string }>(); @@ -177,12 +190,15 @@ const NotePageView: React.FC = () => {
{canEdit ? ( - + + + ) : ( Date: Sat, 4 Apr 2026 22:48:10 +0900 Subject: [PATCH 2/9] fix: address PR #476 review (workspace root ref, Rust path, imports, JSDoc) - useEditor: stable deps + useWorkspaceRootRef + eslint block for extension config - workspace_paths: lexical resolve_under_root with fewer canonicalize calls - useTiptapEditorController: single React import line Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 31 ++++++---- .../editor/TiptapEditor/useEditorSetup.ts | 56 ++++++------------- .../TiptapEditor/useTiptapEditorController.ts | 3 +- 3 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index 7cef7a3a..b0cf3557 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -17,7 +17,8 @@ const DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES: u32 = 500; const HARD_MAX_NOTE_WORKSPACE_ENTRIES: u32 = 2000; /// Resolves `relative` under an already-canonicalized root (traversal-safe). -/// 正規化済みルート配下に `relative` を解決する(トラバーサル対策)。 +/// Lexical joins first, then `canonicalize` on the resolved path or the longest existing prefix. +/// 正規化済みルート配下に解決。字句結合後に終端または最長の存在接頭辞を `canonicalize`。 pub(crate) fn resolve_under_root(root_canon: &PathBuf, relative: &str) -> Result { let trimmed = relative.trim(); if trimmed.is_empty() { @@ -26,16 +27,7 @@ pub(crate) fn resolve_under_root(root_canon: &PathBuf, relative: &str) -> Result let mut acc = root_canon.clone(); for comp in Path::new(trimmed).components() { match comp { - Component::Normal(c) => { - acc.push(c); - if acc.exists() { - let canon = acc.canonicalize().map_err(|e| e.to_string())?; - if !canon.starts_with(root_canon) { - return Err("path outside workspace".into()); - } - acc = canon; - } - } + Component::Normal(c) => acc.push(c), Component::ParentDir => { acc.pop(); if !acc.starts_with(root_canon) { @@ -55,6 +47,23 @@ pub(crate) fn resolve_under_root(root_canon: &PathBuf, relative: &str) -> Result } return Ok(canon); } + // Non-existent leaf: walk up to the longest existing prefix so symlink escapes are still detected. + let mut check = acc.clone(); + loop { + if check.exists() { + let canon = check.canonicalize().map_err(|e| e.to_string())?; + if !canon.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + break; + } + if check == *root_canon { + break; + } + if !check.pop() { + break; + } + } if !acc.starts_with(root_canon) { return Err("path outside workspace".into()); } diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts index 600db114..7be6c0dc 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, type MutableRefObject, @@ -15,6 +16,15 @@ import type { SlashSuggestionHandle } from "./SlashSuggestionLayer"; import { createEditorExtensions, defaultEditorProps } from "./editorConfig"; import type { TiptapEditorProps } from "./types"; +/** Keeps latest `workspaceRoot` in a ref without re-running `useEditor` (Issue #461). */ +function useWorkspaceRootRef(workspaceRoot: string | null) { + const r = useRef(workspaceRoot); + useLayoutEffect(() => { + r.current = workspaceRoot; + }, [workspaceRoot]); + return r; +} + interface UseEditorSetupOptions { content: TiptapEditorProps["content"]; onChange: TiptapEditorProps["onChange"]; @@ -43,13 +53,8 @@ interface UseEditorSetupOptions { workspaceRoot: string | null; } -/** - * - */ +/** Tiptap `useEditor` wiring: extensions, collaboration, note `@file:` root (Issue #461). */ export function useEditorSetup(options: UseEditorSetupOptions) { - /** - * - */ const { content, onChange, @@ -77,18 +82,9 @@ export function useEditorSetup(options: UseEditorSetupOptions) { workspaceRoot, } = options; - /** - * - */ const isEditorInitializedRef = useRef(false); - /** - * - */ const lastReportedContentRef = useRef(null); - /** - * - */ const initialParsedContent = useMemo(() => { if (!content) return undefined; try { @@ -111,31 +107,22 @@ export function useEditorSetup(options: UseEditorSetupOptions) { }); }, [content, initialParsedContent, onContentError]); - /** - * - */ const useCollaborationMode = Boolean( collaborationConfig?.xmlFragment && collaborationConfig?.user, ); - /** - * - */ const slashStateRef = useRef(slashState); - /** - * - */ const suggestionStateRef = useRef(suggestionState); useEffect(() => { slashStateRef.current = slashState; suggestionStateRef.current = suggestionState; }, [slashState, suggestionState]); - /** - * - */ + const workspaceRootRef = useWorkspaceRootRef(workspaceRoot); + const editor = useEditor( { + /* eslint-disable react-hooks/refs -- createEditorExtensions runs at render but refs are only read in ProseMirror/Tiptap handlers (not during render) */ extensions: createEditorExtensions({ placeholder, onLinkClick: handleLinkClick, @@ -153,9 +140,6 @@ export function useEditorSetup(options: UseEditorSetupOptions) { onCopyUrl: handleCopyImageUrl, getAuthenticatedImageUrl: async (url: string) => { try { - /** - * - */ const r = await fetch(url, { credentials: "include", }); @@ -176,9 +160,10 @@ export function useEditorSetup(options: UseEditorSetupOptions) { } : undefined, fileReference: { - getWorkspaceRoot: () => workspaceRoot, + getWorkspaceRoot: () => workspaceRootRef.current, }, }), + /* eslint-enable react-hooks/refs */ content: useCollaborationMode ? undefined : initialParsedContent, autofocus: autoFocus ? "end" : false, editable: !isReadOnly, @@ -204,9 +189,6 @@ export function useEditorSetup(options: UseEditorSetupOptions) { if (initialParsedContent) isEditorInitializedRef.current = true; }, onSelectionUpdate: ({ editor }) => { - /** - * - */ const { from, to } = editor.state.selection; lastSelectionRef.current = { from, to }; if (useCollaborationMode && collaborationConfig?.awareness) { @@ -215,16 +197,14 @@ export function useEditorSetup(options: UseEditorSetupOptions) { } }, }, - [pageId, useCollaborationMode, workspaceRoot], + [pageId, useCollaborationMode], ); useEffect(() => { editorRef.current = editor; }, [editor, editorRef]); - /** - * - */ + /** Inserts a Mermaid diagram at the current selection. / 現在の選択位置に Mermaid を挿入する。 */ const handleInsertMermaid = useCallback( (code: string) => { if (!editor) return; diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index 5be6f611..5db0f61c 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -1,5 +1,4 @@ -import { useRef, useState, type MutableRefObject } from "react"; -import type { MutableRefObject, RefObject } from "react"; +import { useRef, useState, type MutableRefObject, type RefObject } from "react"; import type { Editor } from "@tiptap/core"; import type { WikiLinkSuggestionState } from "../extensions/wikiLinkSuggestionPlugin"; import type { SlashSuggestionState } from "../extensions/slashSuggestionPlugin"; From 036e1db6d24a8cc4e329e2e6d4eb96c0519f81e9 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sat, 4 Apr 2026 23:36:53 +0900 Subject: [PATCH 3/9] fix: address CodeRabbit PR review (paths, @file:, preview, docs) - Rust: re-canonicalize before open; document openat limitation - FileReference: spaces in path regex; stale preview guard - FilePreviewDialogHost: CustomEvent + detail guard - Note workspace: clear entries on tree open; normalize empty root; safe map keys - JSDoc: Japanese for useEditorSetup, aiService cwd, PageContext noteId Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 22 ++++++++++++++ .../editor/TiptapEditor/useEditorSetup.ts | 10 +++++-- .../extensions/FileReferenceExtension.ts | 8 ++++- src/components/note/FilePreviewDialogHost.tsx | 8 +++-- src/components/note/NoteWorkspaceToolbar.tsx | 1 + src/contexts/NoteWorkspaceContext.tsx | 10 +++++-- src/lib/aiService.ts | 5 +++- .../noteWorkspace/noteWorkspaceStore.test.ts | 7 +++++ src/lib/noteWorkspace/noteWorkspaceStore.ts | 29 +++++++++++++++---- src/types/aiChat.ts | 5 +++- 10 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index b0cf3557..c57f28d1 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -70,6 +70,16 @@ pub(crate) fn resolve_under_root(root_canon: &PathBuf, relative: &str) -> Result Ok(acc) } +/// Re-canonicalize immediately before opening to narrow TOCTOU vs symlink swap (not full `openat` hardening). +/// オープン直前に再 canonicalize して TOCTOU を狭める(openat 相当の完全対策ではない)。 +fn assert_still_under_root(root_canon: &PathBuf, path: &Path) -> Result { + let canon = path.canonicalize().map_err(|e| e.to_string())?; + if !canon.starts_with(root_canon) { + return Err("path outside workspace".into()); + } + Ok(canon) +} + fn canonical_note_workspace_root(workspace_root: &str) -> Result { let trimmed = workspace_root.trim(); if trimmed.is_empty() { @@ -94,6 +104,10 @@ pub fn list_workspace_directory_entries(relative_dir: String) -> Result Result Result { let root_canon = canonical_note_workspace_root(&workspace_root)?; let target = resolve_under_root(&root_canon, &relative_path)?; + if !target.exists() { + return Err("not a file".into()); + } + let target = assert_still_under_root(&root_canon, target.as_path())?; if !target.is_file() { return Err("not a file".into()); } @@ -129,6 +147,10 @@ pub fn list_note_workspace_entries( .min(HARD_MAX_NOTE_WORKSPACE_ENTRIES); let root_canon = canonical_note_workspace_root(&workspace_root)?; let target = resolve_under_root(&root_canon, &relative_dir)?; + if !target.exists() { + return Ok(vec![]); + } + let target = assert_still_under_root(&root_canon, target.as_path())?; if !target.is_dir() { return Ok(vec![]); } diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts index 7be6c0dc..91a61e46 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -16,7 +16,10 @@ import type { SlashSuggestionHandle } from "./SlashSuggestionLayer"; import { createEditorExtensions, defaultEditorProps } from "./editorConfig"; import type { TiptapEditorProps } from "./types"; -/** Keeps latest `workspaceRoot` in a ref without re-running `useEditor` (Issue #461). */ +/** + * Keeps latest `workspaceRoot` in a ref without re-running `useEditor` (Issue #461). + * `useEditor` を再実行せずに最新のワークスペースルートを ref に保持する(Issue #461)。 + */ function useWorkspaceRootRef(workspaceRoot: string | null) { const r = useRef(workspaceRoot); useLayoutEffect(() => { @@ -53,7 +56,10 @@ interface UseEditorSetupOptions { workspaceRoot: string | null; } -/** Tiptap `useEditor` wiring: extensions, collaboration, note `@file:` root (Issue #461). */ +/** + * Tiptap `useEditor` wiring: extensions, collaboration, note `@file:` root (Issue #461). + * Tiptap の `useEditor` 拡張・コラボレーション・ノート連動 `@file:` ルート(Issue #461)。 + */ export function useEditorSetup(options: UseEditorSetupOptions) { const { content, diff --git a/src/components/editor/extensions/FileReferenceExtension.ts b/src/components/editor/extensions/FileReferenceExtension.ts index 87e61b1e..64c4fc9e 100644 --- a/src/components/editor/extensions/FileReferenceExtension.ts +++ b/src/components/editor/extensions/FileReferenceExtension.ts @@ -88,7 +88,9 @@ export const FileReference = Mark.create({ addInputRules() { return [ markInputRule({ - find: /(?:^|\s)(@file:[^\s]+)\s$/, + // Path may contain spaces; closing `\s$` ends the token (same as before). + // パスに空白を含められる。末尾の空白でトークン終端。 + find: /(?:^|\s)(@file:(.+))\s$/, type: this.type, getAttributes: (match) => { const full = match[1] ?? ""; @@ -101,6 +103,8 @@ export const FileReference = Mark.create({ addProseMirrorPlugins() { const getRoot = this.options.getWorkspaceRoot; + // Latest click wins if previews overlap. / 連続クリック時は最後の結果のみ反映 + let previewSeq = 0; return [ new Plugin({ key: new PluginKey("fileReferenceClick"), @@ -120,8 +124,10 @@ export const FileReference = Mark.create({ } event.preventDefault(); event.stopPropagation(); + const seq = ++previewSeq; void (async () => { const result = await readNoteWorkspaceFile(root, path); + if (seq !== previewSeq) return; if (result.ok) { const truncated = result.content.length > FILE_PREVIEW_DISPLAY_MAX_CHARS; const content = truncated diff --git a/src/components/note/FilePreviewDialogHost.tsx b/src/components/note/FilePreviewDialogHost.tsx index 0829939c..d3556d59 100644 --- a/src/components/note/FilePreviewDialogHost.tsx +++ b/src/components/note/FilePreviewDialogHost.tsx @@ -16,8 +16,12 @@ export function FilePreviewDialogHost(): React.ReactElement { const [detail, setDetail] = useState(null); const onEvent = useCallback((ev: Event) => { - const ce = ev as CustomEvent; - setDetail(ce.detail ?? null); + if (!(ev instanceof CustomEvent)) return; + const raw = ev.detail; + if (raw == null || typeof raw !== "object") return; + const d = raw as FilePreviewEventDetail; + if (typeof d.relativePath !== "string") return; + setDetail(d); setOpen(true); }, []); diff --git a/src/components/note/NoteWorkspaceToolbar.tsx b/src/components/note/NoteWorkspaceToolbar.tsx index cc84bee7..759e43eb 100644 --- a/src/components/note/NoteWorkspaceToolbar.tsx +++ b/src/components/note/NoteWorkspaceToolbar.tsx @@ -33,6 +33,7 @@ export function NoteWorkspaceToolbar() { const openTree = useCallback(() => { setRelDir(""); + setEntries([]); setTreeOpen(true); void fetchEntries(""); }, [fetchEntries]); diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index 953e4243..a334689d 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -57,8 +57,14 @@ export function NoteWorkspaceProvider({ const setWorkspaceRoot = useCallback( (path: string) => { - writeNoteWorkspacePath(noteId, path); - setWorkspaceRootState(path.trim()); + const normalized = path.trim(); + if (!normalized) { + clearNoteWorkspacePath(noteId); + setWorkspaceRootState(null); + return; + } + writeNoteWorkspacePath(noteId, normalized); + setWorkspaceRootState(normalized); }, [noteId], ); diff --git a/src/lib/aiService.ts b/src/lib/aiService.ts index a5321b2c..d82d9490 100644 --- a/src/lib/aiService.ts +++ b/src/lib/aiService.ts @@ -29,7 +29,10 @@ export interface AIServiceRequest { webSearchOptions?: { search_context_size: "medium" | "low" | "high" }; useWebSearch?: boolean; useGoogleSearch?: boolean; - /** Claude Code sidecar cwd (note-linked workspace, desktop only). */ + /** + * Claude Code sidecar cwd (note-linked workspace, desktop only). + * Claude Code サイドカーの cwd(ノート連動ワークスペース、デスクトップのみ)。 + */ cwd?: string; }; } diff --git a/src/lib/noteWorkspace/noteWorkspaceStore.test.ts b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts index 1604a71e..626cf201 100644 --- a/src/lib/noteWorkspace/noteWorkspaceStore.test.ts +++ b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts @@ -17,4 +17,11 @@ describe("noteWorkspaceStore", () => { clearNoteWorkspacePath("n1"); expect(readNoteWorkspacePath("n1")).toBe(null); }); + + it("rejects reserved note keys (prototype pollution)", () => { + writeNoteWorkspacePath("__proto__", "/evil"); + expect(readNoteWorkspacePath("__proto__")).toBe(null); + writeNoteWorkspacePath("prototype", "/evil"); + expect(readNoteWorkspacePath("prototype")).toBe(null); + }); }); diff --git a/src/lib/noteWorkspace/noteWorkspaceStore.ts b/src/lib/noteWorkspace/noteWorkspaceStore.ts index c172f774..87012194 100644 --- a/src/lib/noteWorkspace/noteWorkspaceStore.ts +++ b/src/lib/noteWorkspace/noteWorkspaceStore.ts @@ -9,18 +9,34 @@ const STORAGE_KEY = "zedi.noteWorkspace.v1"; +/** Keys that must not be used as object property names (prototype pollution). / プロトタイプ汚染を避けるキー */ +const RESERVED_NOTE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + type NoteWorkspaceMap = Record; +function emptyMap(): NoteWorkspaceMap { + return Object.create(null) as NoteWorkspaceMap; +} + +function isSafeNoteKey(noteId: string): boolean { + return !RESERVED_NOTE_KEYS.has(noteId); +} + function readMap(): NoteWorkspaceMap { - if (typeof window === "undefined") return {}; + if (typeof window === "undefined") return emptyMap(); try { const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) return {}; + if (!raw) return emptyMap(); const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object") return {}; - return parsed as NoteWorkspaceMap; + if (!parsed || typeof parsed !== "object") return emptyMap(); + const out = emptyMap(); + for (const [k, v] of Object.entries(parsed)) { + if (!isSafeNoteKey(k)) continue; + if (typeof v === "string") out[k] = v; + } + return out; } catch { - return {}; + return emptyMap(); } } @@ -38,6 +54,7 @@ function writeMap(map: NoteWorkspaceMap): void { * ノートに保存されたワークスペースパスを返す。なければ null。 */ export function readNoteWorkspacePath(noteId: string): string | null { + if (!isSafeNoteKey(noteId)) return null; const v = readMap()[noteId]; return typeof v === "string" && v.trim().length > 0 ? v.trim() : null; } @@ -47,6 +64,7 @@ export function readNoteWorkspacePath(noteId: string): string | null { * ノートのワークスペースパスを保存する。 */ export function writeNoteWorkspacePath(noteId: string, absolutePath: string): void { + if (!isSafeNoteKey(noteId)) return; const map = readMap(); map[noteId] = absolutePath.trim(); writeMap(map); @@ -57,6 +75,7 @@ export function writeNoteWorkspacePath(noteId: string, absolutePath: string): vo * ノートのワークスペースパスを削除する。 */ export function clearNoteWorkspacePath(noteId: string): void { + if (!isSafeNoteKey(noteId)) return; const map = readMap(); if (!(noteId in map)) return; const { [noteId]: _removed, ...rest } = map; diff --git a/src/types/aiChat.ts b/src/types/aiChat.ts index a4cadb1c..6cba11e7 100644 --- a/src/types/aiChat.ts +++ b/src/types/aiChat.ts @@ -160,7 +160,10 @@ export const MAX_REFERENCED_PAGES = 5; export interface PageContext { type: "editor" | "home" | "search" | "other"; pageId?: string; - /** Parent note id when editing a page inside a note (local metadata only). */ + /** + * Parent note id when editing a page inside a note (local metadata only). + * ノート内ページ編集中の親ノート ID(ローカルメタデータのみ)。 + */ noteId?: string; /** * Linked local workspace root for Claude Code cwd (desktop, not sent to API server). From 0490396d8fc32ca68f4c7651754bf68c44ff93e6 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 5 Apr 2026 00:05:38 +0900 Subject: [PATCH 4/9] fix: note workspace registry + noteId IPC for Tauri reads (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust: persist note_id→root registry, read/list by note_id only, enforce size cap with single File::take - TS: register/clear registry from NoteWorkspaceContext; readNoteWorkspaceFile/listNoteWorkspaceEntries use noteId - Editor @file: and slash path completion thread noteId; agent cwd still uses absolute workspace path Made-with: Cursor --- src-tauri/Cargo.lock | 111 +++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 2 + src-tauri/src/workspace_paths.rs | 145 ++++++++++++++---- src/components/editor/TiptapEditor.tsx | 2 + .../TiptapEditor/SlashSuggestionLayer.tsx | 6 +- .../editor/TiptapEditor/editorConfig.ts | 2 + .../TiptapEditor/slashSuggestionMenuProps.ts | 4 +- .../editor/TiptapEditor/useEditorSetup.ts | 17 ++ .../TiptapEditor/useSlashSuggestionMenu.ts | 3 +- .../useSlashSuggestionMenuData.ts | 6 +- .../TiptapEditor/useTiptapEditorController.ts | 6 + .../useWorkspacePathCompletions.ts | 10 +- .../extensions/FileReferenceExtension.ts | 16 +- src/components/note/NoteWorkspaceToolbar.tsx | 7 +- src/contexts/NoteWorkspaceContext.tsx | 20 +++ src/lib/noteWorkspace/noteWorkspaceIo.ts | 45 +++++- 17 files changed, 346 insertions(+), 57 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e9bbbfc1..1b66c870 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -559,13 +559,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -576,7 +597,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2659,6 +2680,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3429,7 +3461,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3479,7 +3511,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4070,7 +4102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4690,6 +4722,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4732,6 +4773,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4789,6 +4845,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4807,6 +4869,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4825,6 +4893,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4855,6 +4929,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4873,6 +4953,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4891,6 +4977,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4909,6 +5001,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5059,7 +5157,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5141,6 +5239,7 @@ dependencies = [ name = "zedi" version = "0.10.0" dependencies = [ + "dirs 5.0.1", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6a7b272c..34fdd133 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +dirs = "5" tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-store = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93980c63..99d8d792 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,8 @@ pub fn run() { claude_sidecar::check_claude_installation, claude_sidecar::claude_list_models, workspace_paths::list_workspace_directory_entries, + workspace_paths::register_note_workspace_root, + workspace_paths::clear_note_workspace_root, workspace_paths::read_note_workspace_file, workspace_paths::list_note_workspace_entries, ]) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index c57f28d1..a914218d 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -1,9 +1,14 @@ //! Workspace-relative paths: process cwd (slash completion) and note-linked roots (Issue #461). //! プロセス cwd 基準(スラッシュ補完)とノート紐付けルート(Issue #461)。 +use std::collections::HashMap; use std::fs; +use std::fs::File; +use std::io::Read; use std::path::{Component, Path, PathBuf}; +use serde::{Deserialize, Serialize}; + /// Maximum bytes returned by {@link read_note_workspace_file}. /// {@link read_note_workspace_file} が返す最大バイト数。 const MAX_NOTE_WORKSPACE_FILE_BYTES: u64 = 512 * 1024; @@ -16,6 +21,13 @@ const DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES: u32 = 500; /// 列挙件数の上限(API 悪用緩和)。 const HARD_MAX_NOTE_WORKSPACE_ENTRIES: u32 = 2000; +/// Persisted mapping note id → canonical workspace root (desktop; Issue #461). +/// ノート ID → 正規化済みワークスペースルートの永続マップ(デスクトップ、Issue #461)。 +#[derive(Debug, Default, Serialize, Deserialize)] +struct NoteWorkspaceRegistry { + roots: HashMap, +} + /// Resolves `relative` under an already-canonicalized root (traversal-safe). /// Lexical joins first, then `canonicalize` on the resolved path or the longest existing prefix. /// 正規化済みルート配下に解決。字句結合後に終端または最長の存在接頭辞を `canonicalize`。 @@ -93,6 +105,95 @@ fn canonical_note_workspace_root(workspace_root: &str) -> Result Result<(), String> { + let t = note_id.trim(); + if t.is_empty() { + return Err("invalid note id".into()); + } + match t { + "__proto__" | "prototype" | "constructor" => Err("invalid note id".into()), + _ => Ok(()), + } +} + +fn registry_file() -> Result { + let dir = dirs::data_dir() + .ok_or_else(|| "no data directory".to_string())? + .join("zedi"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("note_workspace_roots.json")) +} + +fn load_registry() -> Result { + let path = registry_file()?; + if !path.exists() { + return Ok(NoteWorkspaceRegistry::default()); + } + let raw = fs::read_to_string(&path).map_err(|e| e.to_string())?; + serde_json::from_str(&raw).map_err(|e| e.to_string()) +} + +fn save_registry(reg: &NoteWorkspaceRegistry) -> Result<(), String> { + let path = registry_file()?; + let raw = serde_json::to_string_pretty(reg).map_err(|e| e.to_string())?; + fs::write(&path, raw).map_err(|e| e.to_string()) +} + +fn resolve_registered_root(note_id: &str) -> Result { + let reg = load_registry()?; + let s = reg + .roots + .get(note_id) + .ok_or_else(|| "note workspace not registered".to_string())?; + canonical_note_workspace_root(s) +} + +/// Registers the canonical workspace root for a note (used by read/list commands; do not trust raw paths from IPC alone). +/// ノートのワークスペースルートを登録する(読み取りはここ経由。IPC の生パスだけは信用しない)。 +#[tauri::command] +pub fn register_note_workspace_root(note_id: String, workspace_root: String) -> Result<(), String> { + validate_note_id_key(¬e_id)?; + let canon = canonical_note_workspace_root(&workspace_root)?; + let mut reg = load_registry()?; + reg.roots + .insert(note_id, canon.to_string_lossy().to_string()); + save_registry(®) +} + +/// Removes the registered workspace root for a note. +/// ノートの登録済みワークスペースルートを削除する。 +#[tauri::command] +pub fn clear_note_workspace_root(note_id: String) -> Result<(), String> { + validate_note_id_key(¬e_id)?; + let mut reg = load_registry()?; + reg.roots.remove(¬e_id); + save_registry(®) +} + +/// Reads UTF-8 under `root_canon` with a single file handle and a hard byte cap (no metadata/read split). +/// 単一ファイルハンドルでバイト上限を強制(metadata と read の分離によるレースを避ける)。 +fn read_utf8_file_under_root(root_canon: &PathBuf, relative_path: &str) -> Result { + let target = resolve_under_root(root_canon, relative_path)?; + if !target.exists() { + return Err("not a file".into()); + } + let target = assert_still_under_root(root_canon, target.as_path())?; + if !target.is_file() { + return Err("not a file".into()); + } + let cap = MAX_NOTE_WORKSPACE_FILE_BYTES.saturating_add(1); + let mut buf = Vec::new(); + File::open(&target) + .map_err(|e| e.to_string())? + .take(cap) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.len() as u64 > MAX_NOTE_WORKSPACE_FILE_BYTES { + return Err("file too large".into()); + } + String::from_utf8(buf).map_err(|e| e.to_string()) +} + /// Lists file and subdirectory names under `relative_dir` (relative to process cwd). /// `relative_dir` が空なら cwd 直下を列挙する。 /// Directories are suffixed with `/`. Hidden names (leading `.`) are skipped. @@ -114,39 +215,31 @@ pub fn list_workspace_directory_entries(relative_dir: String) -> Result Result { - let root_canon = canonical_note_workspace_root(&workspace_root)?; - let target = resolve_under_root(&root_canon, &relative_path)?; - if !target.exists() { - return Err("not a file".into()); - } - let target = assert_still_under_root(&root_canon, target.as_path())?; - if !target.is_file() { - return Err("not a file".into()); - } - let meta = fs::metadata(&target).map_err(|e| e.to_string())?; - if meta.len() > MAX_NOTE_WORKSPACE_FILE_BYTES { - return Err("file too large".into()); - } - fs::read_to_string(&target).map_err(|e| e.to_string()) +pub fn read_note_workspace_file(note_id: String, relative_path: String) -> Result { + validate_note_id_key(¬e_id)?; + let root_canon = resolve_registered_root(¬e_id)?; + let rel = relative_path.replace('\\', "/"); + read_utf8_file_under_root(&root_canon, &rel) } -/// Lists names in `relative_dir` under `workspace_root` (same shape as {@link list_workspace_directory_entries}). -/// {@link list_workspace_directory_entries} と同じ形で `workspace_root` 配下を列挙する。 +/// Lists names in `relative_dir` under the registered workspace for `note_id`. +/// 登録済み `note_id` のワークスペース配下で `relative_dir` を列挙する。 #[tauri::command] pub fn list_note_workspace_entries( - workspace_root: String, + note_id: String, relative_dir: String, max_entries: Option, ) -> Result, String> { + validate_note_id_key(¬e_id)?; let cap = max_entries .unwrap_or(DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES) .min(HARD_MAX_NOTE_WORKSPACE_ENTRIES); - let root_canon = canonical_note_workspace_root(&workspace_root)?; - let target = resolve_under_root(&root_canon, &relative_dir)?; + let root_canon = resolve_registered_root(¬e_id)?; + let rel = relative_dir.replace('\\', "/"); + let target = resolve_under_root(&root_canon, &rel)?; if !target.exists() { return Ok(vec![]); } @@ -194,7 +287,7 @@ mod tests { } #[test] - fn read_note_workspace_file_reads_utf8() { + fn read_utf8_file_under_root_reads_utf8() { let tmp = tempdir().unwrap(); let sub = tmp.path().join("proj"); fs::create_dir(&sub).unwrap(); @@ -204,11 +297,7 @@ mod tests { drop(file); let root = sub.canonicalize().unwrap(); - let text = read_note_workspace_file( - root.to_string_lossy().to_string(), - "hello.txt".to_string(), - ) - .unwrap(); + let text = read_utf8_file_under_root(&root, "hello.txt").unwrap(); assert!(text.contains("hi")); } } diff --git a/src/components/editor/TiptapEditor.tsx b/src/components/editor/TiptapEditor.tsx index e10e7c18..54aa1f52 100644 --- a/src/components/editor/TiptapEditor.tsx +++ b/src/components/editor/TiptapEditor.tsx @@ -79,6 +79,7 @@ const TiptapEditor: React.FC = ({ claudeAgentSlashAvailable, onSlashAgentBusyChange, claudeWorkspaceRoot, + claudeWorkspaceNoteId, } = useTiptapEditorController({ content, onChange, @@ -141,6 +142,7 @@ const TiptapEditor: React.FC = ({ claudeAgentSlashAvailable={claudeAgentSlashAvailable} onAgentBusyChange={onSlashAgentBusyChange} claudeWorkspaceRoot={claudeWorkspaceRoot} + claudeWorkspaceNoteId={claudeWorkspaceNoteId} /> )} {slashAgentBusy ? : null} diff --git a/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx b/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx index 6f290f63..38cd9eb6 100644 --- a/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx +++ b/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx @@ -21,8 +21,10 @@ interface SlashSuggestionLayerProps { claudeAgentSlashAvailable: boolean; /** Fires while Claude Code runs for an agent command. / エージェント実行中 */ onAgentBusyChange?: (busy: boolean) => void; - /** Note-linked workspace root (desktop). / ノート紐付けワークスペース */ + /** Note-linked workspace root for agent cwd (desktop). / エージェント cwd 用 */ claudeWorkspaceRoot?: string | null; + /** Note id for Tauri path completion (desktop). / パス補完用ノート ID */ + claudeWorkspaceNoteId?: string | null; } /** @@ -38,6 +40,7 @@ export const SlashSuggestionLayer: React.FC = ({ claudeAgentSlashAvailable, onAgentBusyChange, claudeWorkspaceRoot, + claudeWorkspaceNoteId, }) => { if (!suggestionState?.active || !suggestionState.range || !position || !editor) return null; @@ -58,6 +61,7 @@ export const SlashSuggestionLayer: React.FC = ({ claudeAgentSlashAvailable={claudeAgentSlashAvailable} onAgentBusyChange={onAgentBusyChange} claudeWorkspaceRoot={claudeWorkspaceRoot} + claudeWorkspaceNoteId={claudeWorkspaceNoteId} />
); diff --git a/src/components/editor/TiptapEditor/editorConfig.ts b/src/components/editor/TiptapEditor/editorConfig.ts index 024fb3ab..3fe0e5a7 100644 --- a/src/components/editor/TiptapEditor/editorConfig.ts +++ b/src/components/editor/TiptapEditor/editorConfig.ts @@ -109,6 +109,7 @@ export interface EditorExtensionsOptions { */ fileReference?: { getWorkspaceRoot: () => string | null; + getNoteId: () => string | null; }; } @@ -197,6 +198,7 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens }), FileReference.configure({ getWorkspaceRoot: options.fileReference?.getWorkspaceRoot ?? (() => null), + getNoteId: options.fileReference?.getNoteId ?? (() => null), }), WikiLinkSuggestionPlugin.configure({ onStateChange: options.onStateChange, diff --git a/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts b/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts index f0aec4c5..a0190bb8 100644 --- a/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts +++ b/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts @@ -12,6 +12,8 @@ export interface SlashSuggestionMenuProps { onClose: () => void; claudeAgentSlashAvailable: boolean; onAgentBusyChange?: (busy: boolean) => void; - /** Note-linked workspace for agent cwd + path completion (Issue #461). */ + /** Note-linked workspace absolute path for agent cwd (Issue #461). */ claudeWorkspaceRoot?: string | null; + /** Note id for Tauri path completion listing (Issue #461). */ + claudeWorkspaceNoteId?: string | null; } diff --git a/src/components/editor/TiptapEditor/useEditorSetup.ts b/src/components/editor/TiptapEditor/useEditorSetup.ts index 91a61e46..850377e0 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -28,6 +28,18 @@ function useWorkspaceRootRef(workspaceRoot: string | null) { return r; } +/** + * Keeps latest `noteId` in a ref without re-running `useEditor` (Issue #461). + * `useEditor` を再実行せずに最新のノート ID を ref に保持する(Issue #461)。 + */ +function useNoteIdRef(noteId: string | null) { + const r = useRef(noteId); + useLayoutEffect(() => { + r.current = noteId; + }, [noteId]); + return r; +} + interface UseEditorSetupOptions { content: TiptapEditorProps["content"]; onChange: TiptapEditorProps["onChange"]; @@ -54,6 +66,8 @@ interface UseEditorSetupOptions { slashRef: RefObject; /** Note-linked workspace root for `@file:` (Issue #461). / `@file:` 用ワークスペースルート */ workspaceRoot: string | null; + /** Current note id for Tauri workspace registry reads (Issue #461). */ + noteId: string | null; } /** @@ -86,6 +100,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { suggestionRef, slashRef, workspaceRoot, + noteId, } = options; const isEditorInitializedRef = useRef(false); @@ -125,6 +140,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { }, [slashState, suggestionState]); const workspaceRootRef = useWorkspaceRootRef(workspaceRoot); + const noteIdRef = useNoteIdRef(noteId); const editor = useEditor( { @@ -167,6 +183,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { : undefined, fileReference: { getWorkspaceRoot: () => workspaceRootRef.current, + getNoteId: () => noteIdRef.current, }, }), /* eslint-enable react-hooks/refs */ diff --git a/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts index c1bb201b..da008df3 100644 --- a/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts +++ b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts @@ -76,6 +76,7 @@ export function useSlashSuggestionMenu( claudeAgentSlashAvailable, onAgentBusyChange, claudeWorkspaceRoot, + claudeWorkspaceNoteId, } = props; const { t } = useTranslation(); const { toast } = useToast(); @@ -90,7 +91,7 @@ export function useSlashSuggestionMenu( editor, t, claudeAgentSlashAvailable, - claudeWorkspaceRoot ?? null, + claudeWorkspaceNoteId ?? null, ); useEffect(() => { diff --git a/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts b/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts index 07ae0623..7761ae20 100644 --- a/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts +++ b/src/components/editor/TiptapEditor/useSlashSuggestionMenuData.ts @@ -21,8 +21,8 @@ export function useSlashSuggestionMenuData( editor: Editor, t: (key: string) => string, claudeAgentSlashAvailable: boolean, - /** When set, path completion lists this root instead of process cwd (Issue #461). */ - noteWorkspaceRoot: string | null, + /** When set, path completion lists under this note's registered workspace (Issue #461). */ + noteWorkspaceNoteId: string | null, ): { items: ReturnType; pathCompletionEnabled: boolean; @@ -41,7 +41,7 @@ export function useSlashSuggestionMenuData( const pathSuggestions = useWorkspacePathCompletions( pathArgs, pathCompletionEnabled, - noteWorkspaceRoot, + noteWorkspaceNoteId, ); return { items, pathCompletionEnabled, pathArgs, pathSuggestions }; diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index 5db0f61c..038c1dcd 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -53,6 +53,8 @@ function useEditorControllers(args: { handleImageUpload: (files: File[]) => Promise; /** Note-linked workspace root for `@file:` (Issue #461). */ workspaceRoot: string | null; + /** Note id for Tauri workspace registry (Issue #461). */ + noteId: string | null; }) { const { editor, handleInsertMermaid, isEditorInitializedRef } = useEditorSetup({ content: args.content, @@ -79,6 +81,7 @@ function useEditorControllers(args: { suggestionRef: args.suggestionRef, slashRef: args.slashRef, workspaceRoot: args.workspaceRoot, + noteId: args.noteId, }); const suggestionUi = useSuggestionEffects({ @@ -137,6 +140,7 @@ export function useTiptapEditorController({ const { editorFontSizePx } = useGeneralSettings(); const noteWorkspace = useNoteWorkspaceOptional(); const workspaceRoot = noteWorkspace?.workspaceRoot ?? null; + const noteIdForWorkspace = noteWorkspace?.noteId ?? null; const editorContainerRef = useRef(null); const editorRef = useRef(null); const lastSelectionRef = useRef<{ from: number; to: number } | null>(null); @@ -212,6 +216,7 @@ export function useTiptapEditorController({ handleInsertImageClick: imageUpload.handleInsertImageClick, handleImageUpload: imageUpload.handleImageUpload, workspaceRoot, + noteId: noteIdForWorkspace, }); const { handleInsertThumbnailImage } = useThumbnailController( editorRef, @@ -254,5 +259,6 @@ export function useTiptapEditorController({ claudeAgentSlashAvailable, onSlashAgentBusyChange: setSlashAgentBusy, claudeWorkspaceRoot: noteWorkspace?.workspaceRoot ?? null, + claudeWorkspaceNoteId: noteWorkspace?.noteId ?? null, }; } diff --git a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts index 769da8a6..1a836a42 100644 --- a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts +++ b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts @@ -35,8 +35,8 @@ export function parsePathCompletionArgs(args: string): { dir: string; filePrefix export function useWorkspacePathCompletions( args: string, enabled: boolean, - /** When set, list under this root (Issue #461). Else process cwd. */ - noteWorkspaceRoot: string | null, + /** When set, list under the registered workspace for this note (Issue #461). Else process cwd. */ + noteWorkspaceNoteId: string | null, ): string[] { const [items, setItems] = useState([]); @@ -48,8 +48,8 @@ export function useWorkspacePathCompletions( const { dir, filePrefix } = parsePathCompletionArgs(args); let cancelled = false; const t = window.setTimeout(() => { - const promise = noteWorkspaceRoot - ? listNoteWorkspaceEntries(noteWorkspaceRoot, dir) + const promise = noteWorkspaceNoteId + ? listNoteWorkspaceEntries(noteWorkspaceNoteId, dir) : listWorkspaceDirectoryEntries(dir); void promise .then((names) => { @@ -66,7 +66,7 @@ export function useWorkspacePathCompletions( cancelled = true; window.clearTimeout(t); }; - }, [args, enabled, noteWorkspaceRoot]); + }, [args, enabled, noteWorkspaceNoteId]); return items; } diff --git a/src/components/editor/extensions/FileReferenceExtension.ts b/src/components/editor/extensions/FileReferenceExtension.ts index 64c4fc9e..a1b80e50 100644 --- a/src/components/editor/extensions/FileReferenceExtension.ts +++ b/src/components/editor/extensions/FileReferenceExtension.ts @@ -17,6 +17,8 @@ export interface FileReferenceOptions { HTMLAttributes: Record; /** Returns linked workspace root for the current note (desktop). / ノートのワークスペースルート */ getWorkspaceRoot: () => string | null; + /** Note id for Tauri registry-backed reads (do not pass root alone). / Tauri レジストリ読み取り用ノート ID */ + getNoteId: () => string | null; } declare module "@tiptap/core" { @@ -41,6 +43,7 @@ export const FileReference = Mark.create({ return { HTMLAttributes: {}, getWorkspaceRoot: () => null as string | null, + getNoteId: () => null as string | null, }; }, @@ -103,6 +106,7 @@ export const FileReference = Mark.create({ addProseMirrorPlugins() { const getRoot = this.options.getWorkspaceRoot; + const getNoteId = this.options.getNoteId; // Latest click wins if previews overlap. / 連続クリック時は最後の結果のみ反映 let previewSeq = 0; return [ @@ -116,17 +120,27 @@ export const FileReference = Mark.create({ const path = el.getAttribute("data-path"); if (!path) return false; const root = getRoot(); + const noteId = getNoteId(); if (!root) { event.preventDefault(); event.stopPropagation(); dispatchFilePreview({ relativePath: path, noWorkspace: true }); return true; } + if (!noteId) { + event.preventDefault(); + event.stopPropagation(); + dispatchFilePreview({ + relativePath: path, + error: "Note id missing for workspace read.", + }); + return true; + } event.preventDefault(); event.stopPropagation(); const seq = ++previewSeq; void (async () => { - const result = await readNoteWorkspaceFile(root, path); + const result = await readNoteWorkspaceFile(noteId, path); if (seq !== previewSeq) return; if (result.ok) { const truncated = result.content.length > FILE_PREVIEW_DISPLAY_MAX_CHARS; diff --git a/src/components/note/NoteWorkspaceToolbar.tsx b/src/components/note/NoteWorkspaceToolbar.tsx index 759e43eb..e33b43b3 100644 --- a/src/components/note/NoteWorkspaceToolbar.tsx +++ b/src/components/note/NoteWorkspaceToolbar.tsx @@ -18,17 +18,18 @@ export function NoteWorkspaceToolbar() { const [relDir, setRelDir] = useState(""); const root = ctx?.workspaceRoot ?? null; + const noteId = ctx?.noteId ?? null; const fetchEntries = useCallback( async (dir: string) => { - if (!root) { + if (!root || !noteId) { setEntries([]); return; } - const list = await listNoteWorkspaceEntries(root, dir); + const list = await listNoteWorkspaceEntries(noteId, dir); setEntries(list); }, - [root], + [root, noteId], ); const openTree = useCallback(() => { diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index a334689d..50bbb5fb 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -12,7 +12,12 @@ import { readNoteWorkspacePath, writeNoteWorkspacePath, } from "@/lib/noteWorkspace/noteWorkspaceStore"; +import { + clearNoteWorkspaceRoot, + registerNoteWorkspaceRoot, +} from "@/lib/noteWorkspace/noteWorkspaceIo"; import { pickNoteWorkspaceDirectory } from "@/lib/noteWorkspace/pickNoteWorkspaceDirectory"; +import { isTauriDesktop } from "@/lib/platform"; /** * Value provided by {@link NoteWorkspaceProvider} (local workspace path, Issue #461). @@ -55,16 +60,30 @@ export function NoteWorkspaceProvider({ setWorkspaceRootState(readNoteWorkspacePath(noteId)); }, [noteId]); + /** Keep Rust-side registry in sync with localStorage (trusted read/list in Tauri). */ + useEffect(() => { + if (!isTauriDesktop()) return; + void (async () => { + if (workspaceRoot) { + await registerNoteWorkspaceRoot(noteId, workspaceRoot); + } else { + await clearNoteWorkspaceRoot(noteId); + } + })(); + }, [noteId, workspaceRoot]); + const setWorkspaceRoot = useCallback( (path: string) => { const normalized = path.trim(); if (!normalized) { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); + void clearNoteWorkspaceRoot(noteId); return; } writeNoteWorkspacePath(noteId, normalized); setWorkspaceRootState(normalized); + void registerNoteWorkspaceRoot(noteId, normalized); }, [noteId], ); @@ -72,6 +91,7 @@ export function NoteWorkspaceProvider({ const clearWorkspace = useCallback(() => { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); + void clearNoteWorkspaceRoot(noteId); }, [noteId]); const pickWorkspace = useCallback(async () => { diff --git a/src/lib/noteWorkspace/noteWorkspaceIo.ts b/src/lib/noteWorkspace/noteWorkspaceIo.ts index f40df269..f9d6699d 100644 --- a/src/lib/noteWorkspace/noteWorkspaceIo.ts +++ b/src/lib/noteWorkspace/noteWorkspaceIo.ts @@ -7,11 +7,40 @@ import { invoke } from "@tauri-apps/api/core"; import { isTauriDesktop } from "@/lib/platform"; /** - * Reads a UTF-8 file under workspace root (server-validated path). - * ワークスペースルート配下の UTF-8 ファイルを読む(サーバ側でパス検証)。 + * Registers the workspace root for a note in the Rust-side registry (required before read/list). + * Rust 側レジストリにワークスペースルートを登録する(read/list の前提)。 */ -export async function readNoteWorkspaceFile( +export async function registerNoteWorkspaceRoot( + noteId: string, workspaceRoot: string, +): Promise { + if (!isTauriDesktop()) return; + try { + await invoke("register_note_workspace_root", { noteId, workspaceRoot }); + } catch { + /* ignore */ + } +} + +/** + * Clears the registered workspace root for a note. + * ノートの登録済みワークスペースルートを消す。 + */ +export async function clearNoteWorkspaceRoot(noteId: string): Promise { + if (!isTauriDesktop()) return; + try { + await invoke("clear_note_workspace_root", { noteId }); + } catch { + /* ignore */ + } +} + +/** + * Reads a UTF-8 file under the registered workspace for the note (Rust-resolved root). + * 登録済みノートのワークスペース配下の UTF-8 を読む(ルートは Rust で解決)。 + */ +export async function readNoteWorkspaceFile( + noteId: string, relativePath: string, ): Promise<{ ok: true; content: string } | { ok: false; error: string }> { if (!isTauriDesktop()) { @@ -19,7 +48,7 @@ export async function readNoteWorkspaceFile( } try { const content = await invoke("read_note_workspace_file", { - workspaceRoot, + noteId, relativePath: relativePath.replace(/\\/g, "/"), }); return { ok: true, content }; @@ -30,18 +59,18 @@ export async function readNoteWorkspaceFile( } /** - * Lists directory entries (same shape as process-cwd listing). - * ディレクトリエントリを列挙する(プロセス cwd 列挙と同じ形)。 + * Lists directory entries under the registered workspace for the note. + * 登録済みノートのワークスペース配下のエントリを列挙する。 */ export async function listNoteWorkspaceEntries( - workspaceRoot: string, + noteId: string, relativeDir: string, maxEntries?: number, ): Promise { if (!isTauriDesktop()) return []; try { return await invoke("list_note_workspace_entries", { - workspaceRoot, + noteId, relativeDir: relativeDir.replace(/\\/g, "/"), maxEntries: maxEntries ?? null, }); From 8f6bb54e76bc85d240f867d56283a7c108c8294e Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 5 Apr 2026 00:26:37 +0900 Subject: [PATCH 5/9] fix: atomic registry write, async tree guard, surface noteWorkspace I/O errors (#476) - Rust: write registry via temp file + rename; mutex around register/clear RMW - Toolbar: sequence ref so only the latest list result applies - noteWorkspaceIo: propagate invoke errors; callers log or clear UI Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 63 ++++++++++++++++--- .../useWorkspacePathCompletions.ts | 6 +- src/components/note/NoteWorkspaceToolbar.tsx | 19 +++++- src/contexts/NoteWorkspaceContext.tsx | 24 ++++--- src/lib/noteWorkspace/noteWorkspaceIo.ts | 32 ++++------ 5 files changed, 105 insertions(+), 39 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index a914218d..65154e46 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -6,9 +6,52 @@ use std::fs; use std::fs::File; use std::io::Read; use std::path::{Component, Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use serde::{Deserialize, Serialize}; +/// Serializes read-modify-write on the note workspace registry file (concurrent IPC). +/// ノートワークスペースレジストリの read-modify-write を直列化する(並行 IPC)。 +static REGISTRY_MUTEX: OnceLock> = OnceLock::new(); + +fn registry_mutex() -> &'static Mutex<()> { + REGISTRY_MUTEX.get_or_init(|| Mutex::new(())) +} + +/// Runs `f` while holding the registry lock (register/clear only). +/// レジストリロック保持中に `f` を実行する(register/clear のみ)。 +fn with_registry_write_lock(f: F) -> Result +where + F: FnOnce() -> Result, +{ + let _guard = registry_mutex() + .lock() + .map_err(|e| format!("registry mutex poisoned: {e}"))?; + f() +} + +/// Writes `contents` to `path` via a same-directory temp file + rename (crash-safe partial writes). +/// 同一ディレクトリの一時ファイルへ書き込み後にリネーム(クラッシュ時の部分書き込みを避ける)。 +fn atomic_write_file(path: &Path, contents: &[u8]) -> Result<(), String> { + let file_name = path + .file_name() + .ok_or_else(|| "registry path has no file name".to_string())?; + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(".tmp"); + let parent = path + .parent() + .ok_or_else(|| "registry path has no parent".to_string())?; + let tmp_path = parent.join(tmp_name); + fs::write(&tmp_path, contents).map_err(|e| e.to_string())?; + #[cfg(windows)] + { + if path.exists() { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + } + fs::rename(&tmp_path, path).map_err(|e| e.to_string()) +} + /// Maximum bytes returned by {@link read_note_workspace_file}. /// {@link read_note_workspace_file} が返す最大バイト数。 const MAX_NOTE_WORKSPACE_FILE_BYTES: u64 = 512 * 1024; @@ -136,7 +179,7 @@ fn load_registry() -> Result { fn save_registry(reg: &NoteWorkspaceRegistry) -> Result<(), String> { let path = registry_file()?; let raw = serde_json::to_string_pretty(reg).map_err(|e| e.to_string())?; - fs::write(&path, raw).map_err(|e| e.to_string()) + atomic_write_file(&path, raw.as_bytes()) } fn resolve_registered_root(note_id: &str) -> Result { @@ -154,10 +197,12 @@ fn resolve_registered_root(note_id: &str) -> Result { pub fn register_note_workspace_root(note_id: String, workspace_root: String) -> Result<(), String> { validate_note_id_key(¬e_id)?; let canon = canonical_note_workspace_root(&workspace_root)?; - let mut reg = load_registry()?; - reg.roots - .insert(note_id, canon.to_string_lossy().to_string()); - save_registry(®) + with_registry_write_lock(|| { + let mut reg = load_registry()?; + reg.roots + .insert(note_id, canon.to_string_lossy().to_string()); + save_registry(®) + }) } /// Removes the registered workspace root for a note. @@ -165,9 +210,11 @@ pub fn register_note_workspace_root(note_id: String, workspace_root: String) -> #[tauri::command] pub fn clear_note_workspace_root(note_id: String) -> Result<(), String> { validate_note_id_key(¬e_id)?; - let mut reg = load_registry()?; - reg.roots.remove(¬e_id); - save_registry(®) + with_registry_write_lock(|| { + let mut reg = load_registry()?; + reg.roots.remove(¬e_id); + save_registry(®) + }) } /// Reads UTF-8 under `root_canon` with a single file handle and a hard byte cap (no metadata/read split). diff --git a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts index 1a836a42..139f8ad8 100644 --- a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts +++ b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts @@ -58,8 +58,10 @@ export function useWorkspacePathCompletions( const filtered = fp ? names.filter((n) => n.toLowerCase().startsWith(fp)) : names; setItems(filtered.slice(0, 40)); }) - .catch(() => { - if (!cancelled) setItems([]); + .catch((err: unknown) => { + if (cancelled) return; + console.error("[useWorkspacePathCompletions] directory listing failed", err); + setItems([]); }); }, 120); return () => { diff --git a/src/components/note/NoteWorkspaceToolbar.tsx b/src/components/note/NoteWorkspaceToolbar.tsx index e33b43b3..b4a0e894 100644 --- a/src/components/note/NoteWorkspaceToolbar.tsx +++ b/src/components/note/NoteWorkspaceToolbar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { FolderOpen, FolderTree, Trash2 } from "lucide-react"; import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@zedi/ui"; import { useTranslation } from "react-i18next"; @@ -19,6 +19,11 @@ export function NoteWorkspaceToolbar() { const root = ctx?.workspaceRoot ?? null; const noteId = ctx?.noteId ?? null; + /** + * ツリー操作で複数の list が並行しても、古い応答で上書きしない。 + * Latest in-flight list request wins when navigating directories quickly. + */ + const fetchSeqRef = useRef(0); const fetchEntries = useCallback( async (dir: string) => { @@ -26,8 +31,16 @@ export function NoteWorkspaceToolbar() { setEntries([]); return; } - const list = await listNoteWorkspaceEntries(noteId, dir); - setEntries(list); + const seq = ++fetchSeqRef.current; + try { + const list = await listNoteWorkspaceEntries(noteId, dir); + if (seq !== fetchSeqRef.current) return; + setEntries(list); + } catch (e) { + if (seq !== fetchSeqRef.current) return; + console.error("[NoteWorkspaceToolbar] listNoteWorkspaceEntries failed", e); + setEntries([]); + } }, [root, noteId], ); diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index 50bbb5fb..935520c8 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -64,10 +64,14 @@ export function NoteWorkspaceProvider({ useEffect(() => { if (!isTauriDesktop()) return; void (async () => { - if (workspaceRoot) { - await registerNoteWorkspaceRoot(noteId, workspaceRoot); - } else { - await clearNoteWorkspaceRoot(noteId); + try { + if (workspaceRoot) { + await registerNoteWorkspaceRoot(noteId, workspaceRoot); + } else { + await clearNoteWorkspaceRoot(noteId); + } + } catch (e) { + console.error("[NoteWorkspace] Rust registry sync failed", e); } })(); }, [noteId, workspaceRoot]); @@ -78,12 +82,16 @@ export function NoteWorkspaceProvider({ if (!normalized) { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - void clearNoteWorkspaceRoot(noteId); + void clearNoteWorkspaceRoot(noteId).catch((e) => { + console.error("[NoteWorkspace] clearNoteWorkspaceRoot failed", e); + }); return; } writeNoteWorkspacePath(noteId, normalized); setWorkspaceRootState(normalized); - void registerNoteWorkspaceRoot(noteId, normalized); + void registerNoteWorkspaceRoot(noteId, normalized).catch((e) => { + console.error("[NoteWorkspace] registerNoteWorkspaceRoot failed", e); + }); }, [noteId], ); @@ -91,7 +99,9 @@ export function NoteWorkspaceProvider({ const clearWorkspace = useCallback(() => { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - void clearNoteWorkspaceRoot(noteId); + void clearNoteWorkspaceRoot(noteId).catch((e) => { + console.error("[NoteWorkspace] clearNoteWorkspaceRoot failed", e); + }); }, [noteId]); const pickWorkspace = useCallback(async () => { diff --git a/src/lib/noteWorkspace/noteWorkspaceIo.ts b/src/lib/noteWorkspace/noteWorkspaceIo.ts index f9d6699d..b2745c7e 100644 --- a/src/lib/noteWorkspace/noteWorkspaceIo.ts +++ b/src/lib/noteWorkspace/noteWorkspaceIo.ts @@ -9,30 +9,26 @@ import { isTauriDesktop } from "@/lib/platform"; /** * Registers the workspace root for a note in the Rust-side registry (required before read/list). * Rust 側レジストリにワークスペースルートを登録する(read/list の前提)。 + * @throws Tauri invoke が失敗した場合(呼び出し側で catch してログ可)。 + * @throws When Tauri invoke fails (callers may catch and log). */ export async function registerNoteWorkspaceRoot( noteId: string, workspaceRoot: string, ): Promise { if (!isTauriDesktop()) return; - try { - await invoke("register_note_workspace_root", { noteId, workspaceRoot }); - } catch { - /* ignore */ - } + await invoke("register_note_workspace_root", { noteId, workspaceRoot }); } /** * Clears the registered workspace root for a note. * ノートの登録済みワークスペースルートを消す。 + * @throws Tauri invoke が失敗した場合(呼び出し側で catch してログ可)。 + * @throws When Tauri invoke fails (callers may catch and log). */ export async function clearNoteWorkspaceRoot(noteId: string): Promise { if (!isTauriDesktop()) return; - try { - await invoke("clear_note_workspace_root", { noteId }); - } catch { - /* ignore */ - } + await invoke("clear_note_workspace_root", { noteId }); } /** @@ -61,6 +57,8 @@ export async function readNoteWorkspaceFile( /** * Lists directory entries under the registered workspace for the note. * 登録済みノートのワークスペース配下のエントリを列挙する。 + * @throws Tauri invoke が失敗した場合(呼び出し側で catch してログまたは空表示可)。 + * @throws When Tauri invoke fails (callers may catch and log or show empty UI). */ export async function listNoteWorkspaceEntries( noteId: string, @@ -68,13 +66,9 @@ export async function listNoteWorkspaceEntries( maxEntries?: number, ): Promise { if (!isTauriDesktop()) return []; - try { - return await invoke("list_note_workspace_entries", { - noteId, - relativeDir: relativeDir.replace(/\\/g, "/"), - maxEntries: maxEntries ?? null, - }); - } catch { - return []; - } + return await invoke("list_note_workspace_entries", { + noteId, + relativeDir: relativeDir.replace(/\\/g, "/"), + maxEntries: maxEntries ?? null, + }); } From 44f937039ae64a7f9abbc83feef128443e449b36 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 5 Apr 2026 00:50:12 +0900 Subject: [PATCH 6/9] fix: note workspace provider key + serialized Rust registry sync (#476) - Remount NoteWorkspaceProvider on note.id to avoid noteId/workspaceRoot mismatch - Queue register/clear invocations so async completions cannot reorder Made-with: Cursor --- src/contexts/NoteWorkspaceContext.tsx | 49 ++++++++++++++++----------- src/pages/NotePageView.tsx | 2 +- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index 935520c8..27d53eff 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -56,25 +57,33 @@ export function NoteWorkspaceProvider({ readNoteWorkspacePath(noteId), ); + /** + * Serialize Rust registry updates so async completions cannot apply out of order (Issue #461). + * Rust レジストリ更新を直列化し、非同期完了順の逆転で stale が残らないようにする(Issue #461)。 + */ + const rustRegistryQueueRef = useRef>(Promise.resolve()); + + const enqueueRustRegistrySync = useCallback((run: () => Promise) => { + if (!isTauriDesktop()) return; + rustRegistryQueueRef.current = rustRegistryQueueRef.current.then(run).catch((e) => { + console.error("[NoteWorkspace] Rust registry sync failed", e); + }); + }, []); + useEffect(() => { setWorkspaceRootState(readNoteWorkspacePath(noteId)); }, [noteId]); /** Keep Rust-side registry in sync with localStorage (trusted read/list in Tauri). */ useEffect(() => { - if (!isTauriDesktop()) return; - void (async () => { - try { - if (workspaceRoot) { - await registerNoteWorkspaceRoot(noteId, workspaceRoot); - } else { - await clearNoteWorkspaceRoot(noteId); - } - } catch (e) { - console.error("[NoteWorkspace] Rust registry sync failed", e); + enqueueRustRegistrySync(async () => { + if (workspaceRoot) { + await registerNoteWorkspaceRoot(noteId, workspaceRoot); + } else { + await clearNoteWorkspaceRoot(noteId); } - })(); - }, [noteId, workspaceRoot]); + }); + }, [noteId, workspaceRoot, enqueueRustRegistrySync]); const setWorkspaceRoot = useCallback( (path: string) => { @@ -82,27 +91,27 @@ export function NoteWorkspaceProvider({ if (!normalized) { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - void clearNoteWorkspaceRoot(noteId).catch((e) => { - console.error("[NoteWorkspace] clearNoteWorkspaceRoot failed", e); + enqueueRustRegistrySync(async () => { + await clearNoteWorkspaceRoot(noteId); }); return; } writeNoteWorkspacePath(noteId, normalized); setWorkspaceRootState(normalized); - void registerNoteWorkspaceRoot(noteId, normalized).catch((e) => { - console.error("[NoteWorkspace] registerNoteWorkspaceRoot failed", e); + enqueueRustRegistrySync(async () => { + await registerNoteWorkspaceRoot(noteId, normalized); }); }, - [noteId], + [noteId, enqueueRustRegistrySync], ); const clearWorkspace = useCallback(() => { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - void clearNoteWorkspaceRoot(noteId).catch((e) => { - console.error("[NoteWorkspace] clearNoteWorkspaceRoot failed", e); + enqueueRustRegistrySync(async () => { + await clearNoteWorkspaceRoot(noteId); }); - }, [noteId]); + }, [noteId, enqueueRustRegistrySync]); const pickWorkspace = useCallback(async () => { const path = await pickNoteWorkspaceDirectory(); diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index abb3ae0f..ef3c6bc1 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -190,7 +190,7 @@ const NotePageView: React.FC = () => {
{canEdit ? ( - + Date: Sun, 5 Apr 2026 01:18:09 +0900 Subject: [PATCH 7/9] fix: address PR #476 review comments - NotePageEditorEditable: explicit React.JSX.Element return type - NoteWorkspace: Rust sync only in effect (remove duplicate enqueue from setters) - workspace_paths: parse_note_id_key returns trimmed key for registry I/O Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 14 ++++++++------ src/contexts/NoteWorkspaceContext.tsx | 18 ++++++------------ src/pages/NotePageView.tsx | 2 +- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index 65154e46..352ff430 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -148,14 +148,16 @@ fn canonical_note_workspace_root(workspace_root: &str) -> Result Result<(), String> { +/// Validates reserved keys and returns a trimmed registry key (must match lookup keys). +/// 予約キーを検証し、レジストリキー用に trim 済み文字列を返す(参照キーと一致させる)。 +fn parse_note_id_key(note_id: String) -> Result { let t = note_id.trim(); if t.is_empty() { return Err("invalid note id".into()); } match t { "__proto__" | "prototype" | "constructor" => Err("invalid note id".into()), - _ => Ok(()), + _ => Ok(t.to_string()), } } @@ -195,7 +197,7 @@ fn resolve_registered_root(note_id: &str) -> Result { /// ノートのワークスペースルートを登録する(読み取りはここ経由。IPC の生パスだけは信用しない)。 #[tauri::command] pub fn register_note_workspace_root(note_id: String, workspace_root: String) -> Result<(), String> { - validate_note_id_key(¬e_id)?; + let note_id = parse_note_id_key(note_id)?; let canon = canonical_note_workspace_root(&workspace_root)?; with_registry_write_lock(|| { let mut reg = load_registry()?; @@ -209,7 +211,7 @@ pub fn register_note_workspace_root(note_id: String, workspace_root: String) -> /// ノートの登録済みワークスペースルートを削除する。 #[tauri::command] pub fn clear_note_workspace_root(note_id: String) -> Result<(), String> { - validate_note_id_key(¬e_id)?; + let note_id = parse_note_id_key(note_id)?; with_registry_write_lock(|| { let mut reg = load_registry()?; reg.roots.remove(¬e_id); @@ -266,7 +268,7 @@ pub fn list_workspace_directory_entries(relative_dir: String) -> Result Result { - validate_note_id_key(¬e_id)?; + let note_id = parse_note_id_key(note_id)?; let root_canon = resolve_registered_root(¬e_id)?; let rel = relative_path.replace('\\', "/"); read_utf8_file_under_root(&root_canon, &rel) @@ -280,7 +282,7 @@ pub fn list_note_workspace_entries( relative_dir: String, max_entries: Option, ) -> Result, String> { - validate_note_id_key(¬e_id)?; + let note_id = parse_note_id_key(note_id)?; let cap = max_entries .unwrap_or(DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES) .min(HARD_MAX_NOTE_WORKSPACE_ENTRIES); diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index 27d53eff..fd4ab144 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -74,7 +74,10 @@ export function NoteWorkspaceProvider({ setWorkspaceRootState(readNoteWorkspacePath(noteId)); }, [noteId]); - /** Keep Rust-side registry in sync with localStorage (trusted read/list in Tauri). */ + /** + * Single source of Rust registry sync (setters only update local state; avoids duplicate IPC). + * Rust レジストリ同期はここのみ(setter はローカル状態のみ更新し二重 IPC を避ける)。 + */ useEffect(() => { enqueueRustRegistrySync(async () => { if (workspaceRoot) { @@ -91,27 +94,18 @@ export function NoteWorkspaceProvider({ if (!normalized) { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - enqueueRustRegistrySync(async () => { - await clearNoteWorkspaceRoot(noteId); - }); return; } writeNoteWorkspacePath(noteId, normalized); setWorkspaceRootState(normalized); - enqueueRustRegistrySync(async () => { - await registerNoteWorkspaceRoot(noteId, normalized); - }); }, - [noteId, enqueueRustRegistrySync], + [noteId], ); const clearWorkspace = useCallback(() => { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - enqueueRustRegistrySync(async () => { - await clearNoteWorkspaceRoot(noteId); - }); - }, [noteId, enqueueRustRegistrySync]); + }, [noteId]); const pickWorkspace = useCallback(async () => { const path = await pickNoteWorkspaceDirectory(); diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index ef3c6bc1..5b932a65 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -40,7 +40,7 @@ function NotePageEditorEditable({ noteId: string; collaboration: UseCollaborationReturn; isCollaborationEnabled: boolean; -}) { +}): React.JSX.Element { const [editorContent, setEditorContent] = useState(page.content ?? ""); const { setPageContext, contentAppendHandlerRef, insertAtCursorRef } = useAIChatContext(); const noteWorkspace = useNoteWorkspaceOptional(); From 17220930448ef09ea8ba509851292b3b4a4ef4ec Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 5 Apr 2026 01:38:58 +0900 Subject: [PATCH 8/9] fix: address PR #476 review comments - atomic_write_file: rely on fs::rename replace on Windows (drop pre-remove) - list_note_workspace_entries: optional namePrefix filter before cap - NotePageView: NoteWorkspaceProvider wraps read-only editor for @file: context Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 40 ++++++++++++------- .../useWorkspacePathCompletions.ts | 11 ++--- src/lib/noteWorkspace/noteWorkspaceIo.ts | 4 ++ src/pages/NotePageView.tsx | 38 +++++++++--------- 4 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index 352ff430..1e78f246 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -43,12 +43,6 @@ fn atomic_write_file(path: &Path, contents: &[u8]) -> Result<(), String> { .ok_or_else(|| "registry path has no parent".to_string())?; let tmp_path = parent.join(tmp_name); fs::write(&tmp_path, contents).map_err(|e| e.to_string())?; - #[cfg(windows)] - { - if path.exists() { - fs::remove_file(path).map_err(|e| e.to_string())?; - } - } fs::rename(&tmp_path, path).map_err(|e| e.to_string()) } @@ -261,7 +255,7 @@ pub fn list_workspace_directory_entries(relative_dir: String) -> Result Resul } /// Lists names in `relative_dir` under the registered workspace for `note_id`. +/// Optional `name_prefix` filters case-insensitively before the entry cap (large-dir completions). /// 登録済み `note_id` のワークスペース配下で `relative_dir` を列挙する。 +/// `name_prefix` は上限適用前に大小無視で絞り込み(大きいディレクトリの補完向け)。 #[tauri::command] pub fn list_note_workspace_entries( note_id: String, relative_dir: String, max_entries: Option, + name_prefix: Option, ) -> Result, String> { let note_id = parse_note_id_key(note_id)?; let cap = max_entries @@ -296,28 +293,43 @@ pub fn list_note_workspace_entries( if !target.is_dir() { return Ok(vec![]); } - list_directory_names(&target, cap) + let prefix = name_prefix + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + list_directory_names(&target, cap, prefix) } -fn list_directory_names(target: &Path, max_entries: u32) -> Result, String> { +/// Lists entry display names (file name or `name/` for dirs), optional case-insensitive prefix, then sort and cap. +/// エントリ名を列挙し、任意のプレフィックス(大小無視)で絞ってからソートして上限適用。 +fn list_directory_names( + target: &Path, + max_entries: u32, + name_prefix: Option<&str>, +) -> Result, String> { + let prefix_lower = name_prefix.map(|s| s.to_lowercase()); let mut out: Vec = Vec::new(); for entry in fs::read_dir(target).map_err(|e| e.to_string())? { - if out.len() >= max_entries as usize { - break; - } let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } let is_dir = entry.file_type().map_err(|e| e.to_string())?.is_dir(); - out.push(if is_dir { + let display = if is_dir { format!("{name}/") } else { name - }); + }; + if let Some(ref pl) = prefix_lower { + if !display.to_lowercase().starts_with(pl.as_str()) { + continue; + } + } + out.push(display); } out.sort(); + out.truncate(max_entries as usize); Ok(out) } diff --git a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts index 139f8ad8..d08689b7 100644 --- a/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts +++ b/src/components/editor/TiptapEditor/useWorkspacePathCompletions.ts @@ -49,14 +49,15 @@ export function useWorkspacePathCompletions( let cancelled = false; const t = window.setTimeout(() => { const promise = noteWorkspaceNoteId - ? listNoteWorkspaceEntries(noteWorkspaceNoteId, dir) - : listWorkspaceDirectoryEntries(dir); + ? listNoteWorkspaceEntries(noteWorkspaceNoteId, dir, 40, filePrefix) + : listWorkspaceDirectoryEntries(dir).then((names) => { + const fp = filePrefix.toLowerCase(); + return fp ? names.filter((n) => n.toLowerCase().startsWith(fp)) : names; + }); void promise .then((names) => { if (cancelled) return; - const fp = filePrefix.toLowerCase(); - const filtered = fp ? names.filter((n) => n.toLowerCase().startsWith(fp)) : names; - setItems(filtered.slice(0, 40)); + setItems(names.slice(0, 40)); }) .catch((err: unknown) => { if (cancelled) return; diff --git a/src/lib/noteWorkspace/noteWorkspaceIo.ts b/src/lib/noteWorkspace/noteWorkspaceIo.ts index b2745c7e..ca673862 100644 --- a/src/lib/noteWorkspace/noteWorkspaceIo.ts +++ b/src/lib/noteWorkspace/noteWorkspaceIo.ts @@ -64,11 +64,15 @@ export async function listNoteWorkspaceEntries( noteId: string, relativeDir: string, maxEntries?: number, + /** Filter names before the cap (case-insensitive prefix); Issue #461 path completion. */ + namePrefix?: string, ): Promise { if (!isTauriDesktop()) return []; + const trimmed = namePrefix?.trim(); return await invoke("list_note_workspace_entries", { noteId, relativeDir: relativeDir.replace(/\\/g, "/"), maxEntries: maxEntries ?? null, + namePrefix: trimmed && trimmed.length > 0 ? trimmed : null, }); } diff --git a/src/pages/NotePageView.tsx b/src/pages/NotePageView.tsx index 5b932a65..cab87f61 100644 --- a/src/pages/NotePageView.tsx +++ b/src/pages/NotePageView.tsx @@ -189,8 +189,8 @@ const NotePageView: React.FC = () => {
- {canEdit ? ( - + + {canEdit ? ( { collaboration={collaboration} isCollaborationEnabled={isCollaborationEnabled} /> - - ) : ( - undefined} - onContentError={() => undefined} - /> - )} + ) : ( + undefined} + onContentError={() => undefined} + /> + )} +
From 7729a1b3cbe9cf47cd7ac0e5a060f24cb3d84e34 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 5 Apr 2026 08:26:53 +0900 Subject: [PATCH 9/9] fix: address PR #476 review comments - list_directory_names: allow .zedi directory in listings (Issue #461) - NoteWorkspace: enqueue Rust sync on mount + in setters; drop deferred workspaceRoot effect Made-with: Cursor --- src-tauri/src/workspace_paths.rs | 5 +++-- src/contexts/NoteWorkspaceContext.tsx | 29 +++++++++++++++++---------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/workspace_paths.rs b/src-tauri/src/workspace_paths.rs index 1e78f246..fab569c9 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -312,10 +312,11 @@ fn list_directory_names( for entry in fs::read_dir(target).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with('.') { + let is_dir = entry.file_type().map_err(|e| e.to_string())?.is_dir(); + // Skip dotfiles except `.zedi/` (workspace metadata; Issue #461). + if name.starts_with('.') && !(name == ".zedi" && is_dir) { continue; } - let is_dir = entry.file_type().map_err(|e| e.to_string())?.is_dir(); let display = if is_dir { format!("{name}/") } else { diff --git a/src/contexts/NoteWorkspaceContext.tsx b/src/contexts/NoteWorkspaceContext.tsx index fd4ab144..29459998 100644 --- a/src/contexts/NoteWorkspaceContext.tsx +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -70,23 +70,21 @@ export function NoteWorkspaceProvider({ }); }, []); - useEffect(() => { - setWorkspaceRootState(readNoteWorkspacePath(noteId)); - }, [noteId]); - /** - * Single source of Rust registry sync (setters only update local state; avoids duplicate IPC). - * Rust レジストリ同期はここのみ(setter はローカル状態のみ更新し二重 IPC を避ける)。 + * On mount / note change, `key={note.id}` remounts this provider so `useState` reads storage. + * Enqueue Rust registry sync here (same tick as first paint) so Tauri I/O is not ahead of registration. + * `key={note.id}` で再マウント時は `useState` がストレージを読む。ここでは Rust 同期のみ即キュー。 */ useEffect(() => { + const path = readNoteWorkspacePath(noteId); enqueueRustRegistrySync(async () => { - if (workspaceRoot) { - await registerNoteWorkspaceRoot(noteId, workspaceRoot); + if (path) { + await registerNoteWorkspaceRoot(noteId, path); } else { await clearNoteWorkspaceRoot(noteId); } }); - }, [noteId, workspaceRoot, enqueueRustRegistrySync]); + }, [noteId, enqueueRustRegistrySync]); const setWorkspaceRoot = useCallback( (path: string) => { @@ -94,18 +92,27 @@ export function NoteWorkspaceProvider({ if (!normalized) { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); + enqueueRustRegistrySync(async () => { + await clearNoteWorkspaceRoot(noteId); + }); return; } writeNoteWorkspacePath(noteId, normalized); setWorkspaceRootState(normalized); + enqueueRustRegistrySync(async () => { + await registerNoteWorkspaceRoot(noteId, normalized); + }); }, - [noteId], + [noteId, enqueueRustRegistrySync], ); const clearWorkspace = useCallback(() => { clearNoteWorkspacePath(noteId); setWorkspaceRootState(null); - }, [noteId]); + enqueueRustRegistrySync(async () => { + await clearNoteWorkspaceRoot(noteId); + }); + }, [noteId, enqueueRustRegistrySync]); const pickWorkspace = useCallback(async () => { const path = await pickNoteWorkspaceDirectory();