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..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", ] @@ -1766,6 +1787,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" @@ -2653,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" @@ -2747,6 +2785,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 +2824,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" @@ -3386,7 +3461,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3436,7 +3511,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3508,6 +3583,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 +3760,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" @@ -3974,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", @@ -4594,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" @@ -4636,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" @@ -4693,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" @@ -4711,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" @@ -4729,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" @@ -4759,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" @@ -4777,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" @@ -4795,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" @@ -4813,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" @@ -4963,7 +5157,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5045,12 +5239,15 @@ dependencies = [ name = "zedi" version = "0.10.0" dependencies = [ + "dirs 5.0.1", "serde", "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..34fdd133 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +dirs = "5" +tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-store = "2" serde = { version = "1", features = ["derive"] } @@ -24,6 +26,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..99d8d792 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,10 @@ 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, ]) .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..fab569c9 100644 --- a/src-tauri/src/workspace_paths.rs +++ b/src-tauri/src/workspace_paths.rs @@ -1,7 +1,241 @@ -//! 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::collections::HashMap; +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())?; + 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; + +/// 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; + +/// 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`。 +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), + 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); + } + // 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()); + } + 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() { + 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) +} + +/// 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(t.to_string()), + } +} + +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())?; + atomic_write_file(&path, raw.as_bytes()) +} + +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> { + 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()?; + 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> { + let note_id = parse_note_id_key(note_id)?; + 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). +/// 単一ファイルハンドルでバイト上限を強制(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 直下を列挙する。 @@ -13,67 +247,119 @@ 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.exists() { + return Ok(vec![]); + } + let target = assert_still_under_root(&cwd_canon, target.as_path())?; + if !target.is_dir() { + return Ok(vec![]); + } + list_directory_names(&target, DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES, None) +} + +/// Reads a UTF-8 text file under the registered workspace for `note_id`; size-capped via one handle. +/// 登録済み `note_id` のワークスペース配下の UTF-8 を読む(単一ハンドルでサイズ上限)。 +#[tauri::command] +pub fn read_note_workspace_file(note_id: String, relative_path: String) -> Result { + 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) +} + +/// 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 + .unwrap_or(DEFAULT_NOTE_WORKSPACE_MAX_ENTRIES) + .min(HARD_MAX_NOTE_WORKSPACE_ENTRIES); + 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![]); + } + let target = assert_still_under_root(&root_canon, target.as_path())?; if !target.is_dir() { return Ok(vec![]); } + let prefix = name_prefix + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + list_directory_names(&target, cap, prefix) +} + +/// 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 std::fs::read_dir(&target).map_err(|e| e.to_string())? { + 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(); - 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) } -fn resolve_under_cwd(cwd_canon: &PathBuf, relative_dir: &str) -> 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_utf8_file_under_root_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_utf8_file_under_root(&root, "hello.txt").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..54aa1f52 100644 --- a/src/components/editor/TiptapEditor.tsx +++ b/src/components/editor/TiptapEditor.tsx @@ -78,6 +78,8 @@ const TiptapEditor: React.FC = ({ slashAgentBusy, claudeAgentSlashAvailable, onSlashAgentBusyChange, + claudeWorkspaceRoot, + claudeWorkspaceNoteId, } = useTiptapEditorController({ content, onChange, @@ -139,6 +141,8 @@ const TiptapEditor: React.FC = ({ onClose={handleSlashClose} 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 dbb4f8b3..38cd9eb6 100644 --- a/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx +++ b/src/components/editor/TiptapEditor/SlashSuggestionLayer.tsx @@ -21,6 +21,10 @@ interface SlashSuggestionLayerProps { claudeAgentSlashAvailable: boolean; /** Fires while Claude Code runs for an agent command. / エージェント実行中 */ onAgentBusyChange?: (busy: boolean) => void; + /** Note-linked workspace root for agent cwd (desktop). / エージェント cwd 用 */ + claudeWorkspaceRoot?: string | null; + /** Note id for Tauri path completion (desktop). / パス補完用ノート ID */ + claudeWorkspaceNoteId?: string | null; } /** @@ -35,6 +39,8 @@ export const SlashSuggestionLayer: React.FC = ({ onClose, claudeAgentSlashAvailable, onAgentBusyChange, + claudeWorkspaceRoot, + claudeWorkspaceNoteId, }) => { if (!suggestionState?.active || !suggestionState.range || !position || !editor) return null; @@ -54,6 +60,8 @@ export const SlashSuggestionLayer: React.FC = ({ onClose={onClose} 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 25d92452..3fe0e5a7 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,14 @@ 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; + getNoteId: () => string | null; + }; } /** @@ -187,6 +196,10 @@ export function createEditorExtensions(options: EditorExtensionsOptions): Extens WikiLink.configure({ onLinkClick: options.onLinkClick, }), + 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 6d0fbc58..a0190bb8 100644 --- a/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts +++ b/src/components/editor/TiptapEditor/slashSuggestionMenuProps.ts @@ -12,4 +12,8 @@ export interface SlashSuggestionMenuProps { onClose: () => void; claudeAgentSlashAvailable: boolean; onAgentBusyChange?: (busy: boolean) => void; + /** 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 4104f4d7..850377e0 100644 --- a/src/components/editor/TiptapEditor/useEditorSetup.ts +++ b/src/components/editor/TiptapEditor/useEditorSetup.ts @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + 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"; @@ -8,6 +16,30 @@ 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). + * `useEditor` を再実行せずに最新のワークスペースルートを ref に保持する(Issue #461)。 + */ +function useWorkspaceRootRef(workspaceRoot: string | null) { + const r = useRef(workspaceRoot); + useLayoutEffect(() => { + r.current = workspaceRoot; + }, [workspaceRoot]); + 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"]; @@ -17,8 +49,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,10 +62,18 @@ 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; + /** Current note id for Tauri workspace registry reads (Issue #461). */ + noteId: string | null; } +/** + * Tiptap `useEditor` wiring: extensions, collaboration, note `@file:` root (Issue #461). + * Tiptap の `useEditor` 拡張・コラボレーション・ノート連動 `@file:` ルート(Issue #461)。 + */ export function useEditorSetup(options: UseEditorSetupOptions) { const { content, @@ -59,6 +99,8 @@ export function useEditorSetup(options: UseEditorSetupOptions) { slashState, suggestionRef, slashRef, + workspaceRoot, + noteId, } = options; const isEditorInitializedRef = useRef(false); @@ -97,8 +139,12 @@ export function useEditorSetup(options: UseEditorSetupOptions) { suggestionStateRef.current = suggestionState; }, [slashState, suggestionState]); + const workspaceRootRef = useWorkspaceRootRef(workspaceRoot); + const noteIdRef = useNoteIdRef(noteId); + 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, @@ -135,7 +181,12 @@ export function useEditorSetup(options: UseEditorSetupOptions) { user: collaborationConfig.user, } : undefined, + fileReference: { + getWorkspaceRoot: () => workspaceRootRef.current, + getNoteId: () => noteIdRef.current, + }, }), + /* eslint-enable react-hooks/refs */ content: useCollaborationMode ? undefined : initialParsedContent, autofocus: autoFocus ? "end" : false, editable: !isReadOnly, @@ -176,6 +227,7 @@ export function useEditorSetup(options: UseEditorSetupOptions) { 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/useSlashSuggestionMenu.ts b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts index 51a06d2d..da008df3 100644 --- a/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts +++ b/src/components/editor/TiptapEditor/useSlashSuggestionMenu.ts @@ -68,7 +68,16 @@ export function useSlashSuggestionMenu( props: SlashSuggestionMenuProps, ref: Ref, ): UseSlashSuggestionMenuResult { - const { editor, query, range, onClose, claudeAgentSlashAvailable, onAgentBusyChange } = props; + const { + editor, + query, + range, + onClose, + claudeAgentSlashAvailable, + onAgentBusyChange, + claudeWorkspaceRoot, + claudeWorkspaceNoteId, + } = props; const { t } = useTranslation(); const { toast } = useToast(); const [selectedIndex, setSelectedIndex] = useState(0); @@ -82,6 +91,7 @@ export function useSlashSuggestionMenu( editor, t, claudeAgentSlashAvailable, + claudeWorkspaceNoteId ?? null, ); useEffect(() => { @@ -114,6 +124,7 @@ export function useSlashSuggestionMenu( query, editor, range, + claudeCwd: claudeWorkspaceRoot ?? undefined, }); if (err) { toast({ @@ -126,7 +137,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..7761ae20 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 under this note's registered workspace (Issue #461). */ + noteWorkspaceNoteId: 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, + noteWorkspaceNoteId, + ); return { items, pathCompletionEnabled, pathArgs, pathSuggestions }; } diff --git a/src/components/editor/TiptapEditor/useTiptapEditorController.ts b/src/components/editor/TiptapEditor/useTiptapEditorController.ts index c3a95b43..038c1dcd 100644 --- a/src/components/editor/TiptapEditor/useTiptapEditorController.ts +++ b/src/components/editor/TiptapEditor/useTiptapEditorController.ts @@ -1,5 +1,4 @@ -import { useRef, useState } 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"; @@ -14,6 +13,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 +51,10 @@ function useEditorControllers(args: { slashRef: RefObject; handleInsertImageClick: () => void; 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, @@ -76,6 +80,8 @@ function useEditorControllers(args: { slashState: args.slashState, suggestionRef: args.suggestionRef, slashRef: args.slashRef, + workspaceRoot: args.workspaceRoot, + noteId: args.noteId, }); const suggestionUi = useSuggestionEffects({ @@ -132,6 +138,9 @@ export function useTiptapEditorController({ onWikiContentApplied, }: TiptapEditorProps) { 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); @@ -206,6 +215,8 @@ export function useTiptapEditorController({ slashRef: suggestionControllers.slashRef, handleInsertImageClick: imageUpload.handleInsertImageClick, handleImageUpload: imageUpload.handleImageUpload, + workspaceRoot, + noteId: noteIdForWorkspace, }); const { handleInsertThumbnailImage } = useThumbnailController( editorRef, @@ -247,5 +258,7 @@ export function useTiptapEditorController({ slashAgentBusy, 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 38117a1e..d08689b7 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 the registered workspace for this note (Issue #461). Else process cwd. */ + noteWorkspaceNoteId: string | null, +): string[] { const [items, setItems] = useState([]); useEffect(() => { @@ -42,22 +48,28 @@ 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 = noteWorkspaceNoteId + ? 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(() => { - if (!cancelled) setItems([]); + .catch((err: unknown) => { + if (cancelled) return; + console.error("[useWorkspacePathCompletions] directory listing failed", err); + setItems([]); }); }, 120); return () => { cancelled = true; window.clearTimeout(t); }; - }, [args, enabled]); + }, [args, enabled, noteWorkspaceNoteId]); 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..a1b80e50 --- /dev/null +++ b/src/components/editor/extensions/FileReferenceExtension.ts @@ -0,0 +1,165 @@ +/** + * `@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; + /** Note id for Tauri registry-backed reads (do not pass root alone). / Tauri レジストリ読み取り用ノート ID */ + getNoteId: () => 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, + getNoteId: () => 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({ + // 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] ?? ""; + const path = full.startsWith("@file:") ? full.slice(6) : full; + return { path }; + }, + }), + ]; + }, + + addProseMirrorPlugins() { + const getRoot = this.options.getWorkspaceRoot; + const getNoteId = this.options.getNoteId; + // Latest click wins if previews overlap. / 連続クリック時は最後の結果のみ反映 + let previewSeq = 0; + 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(); + 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(noteId, path); + if (seq !== previewSeq) return; + 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..d3556d59 --- /dev/null +++ b/src/components/note/FilePreviewDialogHost.tsx @@ -0,0 +1,68 @@ +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) => { + 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); + }, []); + + 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..b4a0e894 --- /dev/null +++ b/src/components/note/NoteWorkspaceToolbar.tsx @@ -0,0 +1,173 @@ +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"; +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 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) => { + if (!root || !noteId) { + setEntries([]); + return; + } + 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], + ); + + const openTree = useCallback(() => { + setRelDir(""); + setEntries([]); + 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..29459998 --- /dev/null +++ b/src/contexts/NoteWorkspaceContext.tsx @@ -0,0 +1,143 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + clearNoteWorkspacePath, + 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). + * {@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), + ); + + /** + * 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); + }); + }, []); + + /** + * 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 (path) { + await registerNoteWorkspaceRoot(noteId, path); + } else { + await clearNoteWorkspaceRoot(noteId); + } + }); + }, [noteId, enqueueRustRegistrySync]); + + const setWorkspaceRoot = useCallback( + (path: string) => { + const normalized = path.trim(); + 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], + ); + + const clearWorkspace = useCallback(() => { + clearNoteWorkspacePath(noteId); + setWorkspaceRootState(null); + enqueueRustRegistrySync(async () => { + await clearNoteWorkspaceRoot(noteId); + }); + }, [noteId, enqueueRustRegistrySync]); + + 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..d82d9490 100644 --- a/src/lib/aiService.ts +++ b/src/lib/aiService.ts @@ -29,6 +29,11 @@ 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 サイドカーの cwd(ノート連動ワークスペース、デスクトップのみ)。 + */ + cwd?: string; }; } @@ -178,6 +183,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..ca673862 --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceIo.ts @@ -0,0 +1,78 @@ +/** + * 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"; + +/** + * 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; + 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; + await invoke("clear_note_workspace_root", { noteId }); +} + +/** + * 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()) { + return { ok: false, error: "Desktop only." }; + } + try { + const content = await invoke("read_note_workspace_file", { + noteId, + 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 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, + 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/lib/noteWorkspace/noteWorkspaceStore.test.ts b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts new file mode 100644 index 00000000..626cf201 --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceStore.test.ts @@ -0,0 +1,27 @@ +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); + }); + + 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 new file mode 100644 index 00000000..87012194 --- /dev/null +++ b/src/lib/noteWorkspace/noteWorkspaceStore.ts @@ -0,0 +1,84 @@ +/** + * 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"; + +/** 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 emptyMap(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return emptyMap(); + const parsed = JSON.parse(raw) as unknown; + 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 emptyMap(); + } +} + +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 { + if (!isSafeNoteKey(noteId)) return 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 { + if (!isSafeNoteKey(noteId)) return; + const map = readMap(); + map[noteId] = absolutePath.trim(); + writeMap(map); +} + +/** + * Removes the workspace path for a note. + * ノートのワークスペースパスを削除する。 + */ +export function clearNoteWorkspacePath(noteId: string): void { + if (!isSafeNoteKey(noteId)) return; + 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..cab87f61 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; -}) { +}): React.JSX.Element { 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 }>(); @@ -176,29 +189,32 @@ const NotePageView: React.FC = () => {
- {canEdit ? ( - - ) : ( - undefined} - onContentError={() => undefined} - /> - )} + + {canEdit ? ( + + ) : ( + undefined} + onContentError={() => undefined} + /> + )} +
diff --git a/src/types/aiChat.ts b/src/types/aiChat.ts index 0cfa40e8..6cba11e7 100644 --- a/src/types/aiChat.ts +++ b/src/types/aiChat.ts @@ -160,6 +160,16 @@ 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). + * ノート内ページ編集中の親ノート ID(ローカルメタデータのみ)。 + */ + noteId?: string; + /** + * Linked local workspace root for Claude Code cwd (desktop, not sent to API server). + * Claude Code cwd 用のローカルワークスペース(デスクトップ、API サーバには送らない)。 + */ + claudeWorkspaceRoot?: string; pageTitle?: string; pageContent?: string; /** Full editor content for local actions such as AI-driven page updates. */