From 9225843b0978cd091d3140abd5213d9e29981f64 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 29 Jul 2026 12:30:54 +0100 Subject: [PATCH 01/11] (MOT-4274) feat(editor): add the editor worker and its console page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared code workspace: a folder, the buffers open against it, and which folders are expanded, held in the state worker so it is a fact on the bus rather than something a browser tab happens to remember. A file an agent opens appears in the user's tabs; a file the user opens is one the agent can see. The unit is a folder, not a repository. The tree, tabs, editor, finder and content search all work in a plain directory; git adds a branch label and change marks when the root happens to be a repo, and nothing else changes when it is not. No filesystem access of its own. Reads, writes, moves, listings, content search and git all delegate to the shell worker, so its jail and denylist stay the one boundary, and both the recursive walk and the grep are shell's rather than a second implementation. What this adds is the model on top: a unified diff (pure — two strings in, a patch out), path ranking, a save that refuses to clobber a file that moved since it was opened, a move that rewrites every open buffer beneath it, and a delete that closes them. Those last two are why moves and deletes do not go through shell::fs directly: a buffer left pointing at a path that moved or vanished writes itself back there on the next save. The git write surface is deliberately narrow — commit, fetch, ff-only pull, push, stash, undo-last-commit. Pull is --ff-only because a merge under open buffers produces a conflicted tree an editor cannot usefully show; anything beyond this set stays with shell::exec. The injected console page is a second view of the same workspace: file tree with a Files/Search switch and a git action bar, tabs, the shared Monaco editor, an unsaved-diff toggle, and the conflict guard surfaced as a dialog. Folder expansion round-trips through the worker, so it survives a reload. The page polls the working tree, so an edit landing from anywhere lights the row up and pulls an untouched tab forward. editor::* calls render as themselves in chat: a diff as a diff, a save as a file card with its line counts. --url reads III_URL, which the worker manager injects — inside a sandbox the engine is on the VM gateway, never on the VM's loopback. Reading functions are allowlisted in iii-permissions.yaml. The writes, the git write surface, the workspace repoint, and closing someone else's buffer stay at the needs_approval default. --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + README.md | 1 + editor/Cargo.lock | 2271 +++++++++++++++++ editor/Cargo.toml | 38 + editor/README.md | 176 ++ editor/build.rs | 179 ++ editor/config.yaml | 4 + editor/iii.worker.yaml | 13 + editor/skills/SKILL.md | 119 + editor/src/bus.rs | 307 +++ editor/src/config.rs | 210 ++ editor/src/configuration.rs | 207 ++ editor/src/diff.rs | 251 ++ editor/src/functions/mod.rs | 1217 +++++++++ editor/src/functions/types.rs | 526 ++++ editor/src/fuzzy.rs | 188 ++ editor/src/git.rs | 337 +++ editor/src/lang.rs | 98 + editor/src/lib.rs | 21 + editor/src/main.rs | 109 + editor/src/manifest.rs | 55 + editor/src/surface.rs | 75 + editor/src/tree.rs | 158 ++ editor/src/ui.rs | 65 + editor/src/workspace.rs | 244 ++ .../golden/schemas/editor.buffers.close.json | 69 + .../golden/schemas/editor.buffers.list.json | 55 + .../tests/golden/schemas/editor.create.json | 77 + .../tests/golden/schemas/editor.delete.json | 81 + editor/tests/golden/schemas/editor.diff.json | 140 + editor/tests/golden/schemas/editor.find.json | 95 + .../golden/schemas/editor.git.commit.json | 57 + .../golden/schemas/editor.git.hunks.json | 203 ++ .../tests/golden/schemas/editor.git.show.json | 61 + .../golden/schemas/editor.git.stash.json | 69 + .../golden/schemas/editor.git.status.json | 107 + .../tests/golden/schemas/editor.git.sync.json | 107 + .../schemas/editor.git.undo-commit.json | 38 + editor/tests/golden/schemas/editor.move.json | 84 + editor/tests/golden/schemas/editor.open.json | 58 + editor/tests/golden/schemas/editor.save.json | 94 + .../tests/golden/schemas/editor.search.json | 109 + editor/tests/golden/schemas/editor.tree.json | 75 + .../golden/schemas/editor.workspace.get.json | 65 + .../golden/schemas/editor.workspace.open.json | 74 + editor/tests/integration.rs | 125 + editor/tests/manifest.rs | 44 + editor/tests/schemas.rs | 111 + editor/tests/support/mod.rs | 118 + editor/ui/build.mjs | 37 + editor/ui/package.json | 18 + editor/ui/page.tsx | 29 + .../ui/src/function-trigger-message/index.tsx | 311 +++ editor/ui/src/lib/api.ts | 260 ++ editor/ui/src/page/index.tsx | 878 +++++++ editor/ui/styles.css | 596 +++++ editor/ui/tsconfig.json | 14 + iii-permissions.yaml | 21 + pnpm-lock.yaml | 16 + pnpm-workspace.yaml | 1 + 61 files changed, 11168 insertions(+) create mode 100644 editor/Cargo.lock create mode 100644 editor/Cargo.toml create mode 100644 editor/README.md create mode 100644 editor/build.rs create mode 100644 editor/config.yaml create mode 100644 editor/iii.worker.yaml create mode 100644 editor/skills/SKILL.md create mode 100644 editor/src/bus.rs create mode 100644 editor/src/config.rs create mode 100644 editor/src/configuration.rs create mode 100644 editor/src/diff.rs create mode 100644 editor/src/functions/mod.rs create mode 100644 editor/src/functions/types.rs create mode 100644 editor/src/fuzzy.rs create mode 100644 editor/src/git.rs create mode 100644 editor/src/lang.rs create mode 100644 editor/src/lib.rs create mode 100644 editor/src/main.rs create mode 100644 editor/src/manifest.rs create mode 100644 editor/src/surface.rs create mode 100644 editor/src/tree.rs create mode 100644 editor/src/ui.rs create mode 100644 editor/src/workspace.rs create mode 100644 editor/tests/golden/schemas/editor.buffers.close.json create mode 100644 editor/tests/golden/schemas/editor.buffers.list.json create mode 100644 editor/tests/golden/schemas/editor.create.json create mode 100644 editor/tests/golden/schemas/editor.delete.json create mode 100644 editor/tests/golden/schemas/editor.diff.json create mode 100644 editor/tests/golden/schemas/editor.find.json create mode 100644 editor/tests/golden/schemas/editor.git.commit.json create mode 100644 editor/tests/golden/schemas/editor.git.hunks.json create mode 100644 editor/tests/golden/schemas/editor.git.show.json create mode 100644 editor/tests/golden/schemas/editor.git.stash.json create mode 100644 editor/tests/golden/schemas/editor.git.status.json create mode 100644 editor/tests/golden/schemas/editor.git.sync.json create mode 100644 editor/tests/golden/schemas/editor.git.undo-commit.json create mode 100644 editor/tests/golden/schemas/editor.move.json create mode 100644 editor/tests/golden/schemas/editor.open.json create mode 100644 editor/tests/golden/schemas/editor.save.json create mode 100644 editor/tests/golden/schemas/editor.search.json create mode 100644 editor/tests/golden/schemas/editor.tree.json create mode 100644 editor/tests/golden/schemas/editor.workspace.get.json create mode 100644 editor/tests/golden/schemas/editor.workspace.open.json create mode 100644 editor/tests/integration.rs create mode 100644 editor/tests/manifest.rs create mode 100644 editor/tests/schemas.rs create mode 100644 editor/tests/support/mod.rs create mode 100644 editor/ui/build.mjs create mode 100644 editor/ui/package.json create mode 100644 editor/ui/page.tsx create mode 100644 editor/ui/src/function-trigger-message/index.tsx create mode 100644 editor/ui/src/lib/api.ts create mode 100644 editor/ui/src/page/index.tsx create mode 100644 editor/ui/styles.css create mode 100644 editor/ui/tsconfig.json diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 6628f0e32..ed5bf52b4 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -21,6 +21,7 @@ on: - context-manager - cron - database + - editor - email - eval - harness diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa130835f..10eb06084 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ on: - 'context-manager/v*' - 'cron/v*' - 'database/v*' + - 'editor/v*' - 'email/v*' - 'eval/v*' - 'harness/v*' diff --git a/README.md b/README.md index f76e1c965..5bef39623 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ npx skills add iii-hq/iii --all | [`slack`](slack/) | Rust | Slack Web API as `slack::*` functions plus a harness bridge — @mention-triggered turns, native `chat.*Stream` replies, Block Kit approvals. See [`slack/architecture/`](slack/architecture/). | | [`context-manager`](context-manager/) | Rust | Model-ready context assembly — four `context::*` functions for token counting, function-result pruning, and history compaction over caller-supplied messages. Storage-agnostic; summarisation via `llm-router` when installed. | | [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | +| [`editor`](editor/) | Rust | A shared code workspace — open buffers, file tree, unified diffs, fuzzy find and conflict-safe saves, held in `state` so an agent and a person see one editor. Files and git go through `shell`; ships a console editor page. | | [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. | | [`lsp`](lsp/) | Rust | Language Server for iii function ids, trigger configs, and worker discovery. Autocomplete / hover across JS/TS, Python, Rust. | | [`lsp-vscode`](lsp-vscode/) | Node | VS Code extension package `iii-lsp`, embedding the `lsp` server. | diff --git a/editor/Cargo.lock b/editor/Cargo.lock new file mode 100644 index 000000000..f661a7b05 --- /dev/null +++ b/editor/Cargo.lock @@ -0,0 +1,2271 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "editor" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "iii-console-ui", + "iii-sdk", + "schemars", + "serde", + "serde_json", + "serde_yml", + "similar", + "tokio", + "tracing", + "tracing-subscriber", + "which", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "iii-helpers" +version = "0.21.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bdc7bbc3abfde934a62cdc5d3045adf52914dfc1ed6c20f8af691fc561dc55" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/editor/Cargo.toml b/editor/Cargo.toml new file mode 100644 index 000000000..a577fad13 --- /dev/null +++ b/editor/Cargo.toml @@ -0,0 +1,38 @@ +[workspace] + +[package] +name = "editor" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "editor" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +# Lockstep with every other worker and with `crates/console-ui`, which this +# binary links: two iii-sdk versions in one process means two incompatible +# `IIIClient` types. +iii-sdk = "=0.21.6" +iii-console-ui = { path = "../crates/console-ui" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = "0.0.12" +schemars = "0.8" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +# Myers line diff. `editor::diff` is a pure function over two strings, so the +# only thing this pulls in is the algorithm itself. +similar = "2" + +[dev-dependencies] +serde_json = "1" +which = "8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } diff --git a/editor/README.md b/editor/README.md new file mode 100644 index 000000000..25d35e51d --- /dev/null +++ b/editor/README.md @@ -0,0 +1,176 @@ +# editor + +A line an agent wrote through shell::fs::write, without ever calling editor::*. + +A code workspace that an agent and a person share. Open a folder, and the +buffers you have open, the folders you have expanded, and the mtimes each +buffer was read at are one record on the bus — so the file an agent opens +appears in your tabs, and the file you open is one the agent can see. + +The unit is a **folder**, not a repository. The tree, the tabs, the editor and +the finder all work in a plain directory; git adds a branch label and change +marks when the root happens to be a repo, and nothing else changes when it +is not. + +It opens no files itself. Reads, writes, moves, listings and `git` all go +through the [`shell`](../shell/) worker, so shell's jail and denylist are the +only filesystem boundary; the workspace record lives in [`state`](../state/). +What `editor` adds is the model on top: diffing, ranking paths, refusing a +stale write, and keeping open buffers correct when a folder moves under them. + +## Install + +```bash +iii worker add editor +iii worker add shell # required — editor has no filesystem access of its own +iii worker add state # required — the workspace record lives here +``` + +### Companion workers + +| Worker | Why | +|---|---| +| [`shell`](../shell/) | Required. Every read, write, move, listing (`coder::tree`) and `git` invocation. Its `fs.host_roots` jail governs which paths `editor` can reach. | +| [`state`](../state/) | Required. Holds the active root and one session per project (open buffers, expanded folders). | +| [`console`](../console/) | Optional. Renders the `#/ext/editor` page and the `editor::*` chat cards. | + +## Quickstart + +```rust +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{register_worker, InitOptions}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + let call = |id: &str, payload| iii.trigger(TriggerRequest { + function_id: id.into(), payload, action: None, timeout_ms: Some(30_000), + }); + + // Any folder. No repository required. + call("editor::workspace::open", json!({ "root": "/srv/app" })).await?; + + let file = call("editor::open", json!({ "path": "src/main.rs" })).await?; + let edited = file["content"].as_str().unwrap().replace("TODO", "done"); + + // Show the change before making it. + let preview = call("editor::diff", json!({ + "before": file["content"], "after": edited, "path": "src/main.rs", + })).await?; + println!("{}", preview["patch"].as_str().unwrap()); + + // The mtime from the open is what makes this safe: if anything else + // touched the file in between, nothing is written and the divergence + // comes back as a patch. + let saved = call("editor::save", json!({ + "path": "src/main.rs", "content": edited, "expected_mtime": file["mtime"], + })).await?; + if saved["conflict"] == true { + println!("refused:\n{}", saved["conflict_patch"].as_str().unwrap()); + } + Ok(()) +} +``` + +## Functions + +| Function | Does | +|---|---| +| `editor::workspace::open` | Point the workspace at a folder. Returns the buffers and expanded folders remembered for it. | +| `editor::workspace::get` | The active root, open buffers, and expanded folders — what every surface sees. | +| `editor::tree` | List a folder, with the workspace's expansion state. The walk, the noise-folder excludes and the jail are shell's. | +| `editor::open` | Read a text file and record it as an open buffer, with its language id and the mtime to save against. | +| `editor::save` | Whole-file write, refused when the file moved since the open it started from. The refusal carries the disk-vs-yours diff. | +| `editor::buffers::list` | Files currently open. | +| `editor::buffers::close` | Close one buffer. The file on disk is untouched. | +| `editor::move` | Move or rename, then rewrite every open buffer and expanded folder at or under the path. | +| `editor::create` | Create a file or folder, parents included. A file may be seeded with content. | +| `editor::delete` | Remove a path and close any buffer it held. | +| `editor::find` | Fuzzy file finder, ranked basename-first. Candidates from git in a repo, from the folder listing otherwise. | +| `editor::search` | Search file contents across the workspace, grouped by file. shell's recursive grep, shaped for a results panel. | +| `editor::diff` | Unified patch between two texts. Pure — no path is read, so it works on content that is not on disk yet. | +| `editor::git::status` | Branch, upstream, ahead/behind, and one typed row per changed path. | +| `editor::git::hunks` | What changed in one file: the rendered patch plus its line ranges. | +| `editor::git::show` | A file's contents at a revision, HEAD by default. Pair it with the working copy to render a diff without parsing a patch. | +| `editor::git::commit` | Stage and commit. `committed: false` when there was nothing staged. | +| `editor::git::sync` | Fetch, fast-forward pull, or push, with ahead/behind after. | +| `editor::git::stash` | Stash the working tree, or pop the most recent stash. | +| `editor::git::undo-commit` | `reset --soft HEAD~1`, returning the SHA and message undone. | + +Pull is `--ff-only` on purpose: a merge under open buffers is how an editor +ends up showing a conflicted tree nobody asked for. A repository that needs +interactive credentials will hit `git_timeout_ms` rather than hang, because +`shell::exec` owns the process. + +Two are worth calling out. `editor::diff` is the one an agent reaches for most: +it can show exactly what a write will change before making it. And `editor::move` +exists because `shell::fs::mv` alone leaves open buffers pointing at the old +path — the next save then writes them back there, silently recreating the folder +that was just moved. + +## Console page + +`#/ext/editor` is a view over the same workspace: a collapsible file tree on +the left with a files/search switch, tabs and the shared Monaco editor on the +right, an edit/diff toggle, and a save that surfaces the conflict guard as a +dialog. A status line under the editor carries the file's language, line count +and git deltas — the shared `CodeEditor` is deliberately chrome-less (no line +numbers, no glyph margin) and the SOP forbids bundling another editor, so that +line is where a gutter's information goes. A git strip along the bottom does +commit, fetch, pull, push, stash and pop. Folder expansion round-trips through the worker, so it +survives a reload and both surfaces agree on it. It polls the working tree, so a +file an agent edits lights up as the edit lands and an open tab you have not +typed in reloads under you. A tab you *have* edited is never reloaded; it is +flagged, and the conflict guard decides the outcome. + +`editor::*` calls also render as themselves in chat and traces: a diff as a +diff, a save as a file card with its line counts. + +The diff view is rendered by the worker rather than borrowed from the +console's own diff cards. Those are backed by a library that is already +loaded in the console but is not exposed through `@iii-dev/console-ui`, and +bundling a second copy into this worker's asset measures at 10.3 MB, over +the 8 MiB per-asset cap. So the page draws its own: one row per line, added +and removed lines banded, hunk headers, and the file's real line numbers +taken from the `@@` headers. It gives up syntax highlighting inside the +diff. If a shared diff component is ever added to `@iii-dev/console-ui`, +this page should use it and drop the local renderer. + +## Configuration + +Runtime config lives in the `configuration` worker under id `editor`, so the +console's Workers tab can edit it and every field hot-reloads — handlers read +the live snapshot per call, and nothing needs a restart. There is no committed +`config.yaml`; these are the defaults seeded when nothing is stored yet, and +`--config ` is an optional one-time seed that never overwrites a stored +value. + +Every field is a bound. Nothing here grants access — that is `shell`'s config. + +```yaml +max_diff_bytes: 2000000 # per side of editor::diff, bytes +diff_context_lines: 3 # unchanged lines kept around each hunk +find_limit: 50 # rows returned by editor::find +max_find_candidates: 50000 # paths scanned per editor::find call +max_file_bytes: 2000000 # largest file editor::open will pull back +search_max_matches: 2000 # matching lines editor::search collects +git_timeout_ms: 15000 # per git invocation handed to shell::exec +``` + +## Local development & testing + +```bash +cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yaml +cargo test +``` + +`--url` also reads `III_URL`, which the worker manager injects — inside a +sandbox the engine is on the VM's gateway, never on its loopback. + +The console assets are built from `ui/` by `build.rs`. For the hot-reload loop: + +```bash +cd ui && pnpm install && pnpm watch # esbuild --watch → dist/ +III_EDITOR_UI_WATCH=1 cargo run # re-registers each changed asset +``` diff --git a/editor/build.rs b/editor/build.rs new file mode 100644 index 000000000..aacf44209 --- /dev/null +++ b/editor/build.rs @@ -0,0 +1,179 @@ +//! Build script for the `editor` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the console worker's `web/` precedent). Set +//! `SKIP_UI_BUILD=1` to use the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/page.tsx"); + println!("cargo:rerun-if-changed=ui/styles.css"); + println!("cargo:rerun-if-changed=ui/src"); + println!("cargo:rerun-if-changed=ui/build.mjs"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); +} diff --git a/editor/config.yaml b/editor/config.yaml new file mode 100644 index 000000000..61c060b62 --- /dev/null +++ b/editor/config.yaml @@ -0,0 +1,4 @@ +# iii engine configuration. +# Add workers with `iii worker add `; a running engine reloads on save. +# Configure workers at runtime through the configuration worker (configuration::set). +workers: [] diff --git a/editor/iii.worker.yaml b/editor/iii.worker.yaml new file mode 100644 index 000000000..80bd09c31 --- /dev/null +++ b/editor/iii.worker.yaml @@ -0,0 +1,13 @@ +iii: v1 +name: editor +language: rust +deploy: binary +manifest: Cargo.toml +bin: editor +tags: [editor, diff, git, code, review] +description: A shared code workspace — open buffers, a file tree, unified diffs, fuzzy find and conflict-safe saves that an agent and a person see the same view of, plus a console editor page. + +dependencies: + shell: "^0.10.3" + state: "^0.21.3" + configuration: "^0.21.6" diff --git a/editor/skills/SKILL.md b/editor/skills/SKILL.md new file mode 100644 index 000000000..9668b34ff --- /dev/null +++ b/editor/skills/SKILL.md @@ -0,0 +1,119 @@ +--- +name: editor +description: >- + A code workspace shared with the user — open buffers, a file tree, diffs, + fuzzy find, and saves that refuse to clobber. Backed by the shell worker for + files and the state worker for the workspace record. +--- + +# editor + +The editor worker holds a **workspace**: a folder, the buffers open against it, +and which folders are expanded. That record is shared, not private to you — a +file you open with `editor::open` appears in the user's tabs, and a file they +have open is one you can see with `editor::workspace::get`. + +The unit is a folder, not a repository. Everything works in a plain directory; +git only adds a branch and change marks when there is one. + +It performs no filesystem access itself: reads, writes, moves and listings are +delegated to `shell`, so anything `shell` refuses, `editor` refuses too. + +## The workspace + +Everything is relative to one **workspace**: a root folder, the buffers open +against it, and which folders are expanded. It is shared, so it is also how you +tell the user what you are doing. + +- `editor::workspace::get` tells you the root and what is already open. Read it + before assuming anything about where you are. +- `editor::workspace::open` repoints it. That changes what every surface sees, + including the user's screen, so do not do it casually mid-task. +- `editor::tree` lists a folder and carries the expansion state; passing + `expand` or `collapse` persists it for both surfaces. +- `editor::buffers::list` and `::close` are the tab set. Closing one closes it + for the user too. + +## When to Use + +- You are about to write a file and want to show the change first + (`editor::diff` — pure, no path required). +- You want the user to see what you are working on: `editor::open` puts it in + their editor, which is better than pasting the file into the conversation. +- You need to know what they are looking at (`editor::workspace::get`). +- You are editing across several turns and must not clobber a concurrent edit + (`editor::open` for the mtime, then `editor::save` with `expected_mtime`). +- You are renaming or moving something (`editor::move` — never `shell::fs::mv` + when buffers may be open; see below). +- You know roughly what a file is called but not where it lives + (`editor::find`); you want to find it by its *contents* (`editor::search`). +- You are creating or removing files (`editor::create`, `editor::delete` — + delete closes any buffer beneath the path, which `shell::fs::rm` does not). +- You are committing or syncing (`editor::git::commit`, `::sync`, `::stash`, + `::undo-commit`). +- You want a file as it was at a revision rather than as it is now + (`editor::git::show`, HEAD by default). Pair it with `editor::open` to + diff the two sides yourself rather than parsing a patch. +- You want the working tree as data rather than porcelain text + (`editor::git::status`, `editor::git::hunks`). + +## Boundaries + +- `editor::find` matches **paths**; `editor::search` matches **contents**. + Listing a directory outside the workspace is still `shell::fs::ls`. +- Not a full git client. Status, hunks, tracked paths, commit, fetch/pull/push, + stash and undo-last-commit are covered. Anything else — branch, checkout, + rebase, cherry-pick, remote management — goes through `shell::exec`. + `editor::git::sync` pulls `--ff-only`; a merge is deliberately not offered, + because a conflicted tree under open buffers is a mess an editor cannot + usefully show. +- Not a way around the jail. A path `shell` rejects comes back as `shell`'s + error, unchanged. +- `editor::save` writes the **whole file**. It is not a patch applier — build + the complete new content, then save it. +- Binary files are refused, not mangled. + +## The two rules that prevent data loss + +**Save against the mtime you opened at.** + +1. `editor::open` returns `mtime`. +2. Pass it back as `expected_mtime` on `editor::save`. +3. If the file changed in between, **nothing is written**: the response carries + `conflict: true`, the current `disk_mtime`, and `conflict_patch` — a diff + from what is on disk now to what you tried to write. + +Re-open, reconcile against that patch, save again with the fresh mtime. Do not +retry with `expected_mtime` omitted to force it through; that is exactly the +clobber the guard exists to prevent. Omit it only when creating a new file. + +**Move through `editor::move`, not `shell::fs::mv`.** + +`editor::move` rewrites every open buffer and expanded folder at or under the +path. `shell::fs::mv` does not, so buffers keep pointing at the old location +and the next save writes them back there — silently recreating the folder that +was just moved. + +## Reading a response + +- `editor::diff` — `identical: true` means the texts match. `truncated: true` + means a side was over `max_diff_bytes` and **no diff was computed**; it does + not mean "no changes". +- `editor::open` — `truncated: true` means the file was over `max_file_bytes` + and you hold only its beginning. It is deliberately *not* recorded as a + buffer, and saving it back is refused, because that would delete the rest. +- `editor::find` — `from_git: false` means the folder is not a repository and + candidates came from the directory walk. `truncated: true` means only the + first `max_find_candidates` paths were ranked; narrow the query. +- `editor::git::hunks` — empty `hunks` with `untracked: true` means git has + never seen the file, so there was nothing to compare against. +- `editor::git::status` failing with "not a git repository" is an absent + overlay, not a broken workspace. Everything else still works. +- `editor::search` — paths come back root-relative, like every other + function here. `truncated: true` means the search stopped at + `search_max_matches`. +- `editor::git::commit` — `committed: false` with a summary is "nothing to + commit", not a failure. Do not retry it. +- `editor::git::show` — `exists: false` with empty content means the path is + absent at that revision, which is what a newly added file looks like. It + is not an error. diff --git a/editor/src/bus.rs b/editor/src/bus.rs new file mode 100644 index 000000000..2026ae734 --- /dev/null +++ b/editor/src/bus.rs @@ -0,0 +1,307 @@ +//! Everything this worker does to a filesystem, it asks another worker to do. +//! +//! `editor` deliberately owns no jail, no roots and no denylist. `shell` +//! already has all three, and the repo consolidated its file surface into that +//! one worker precisely so there would be a single boundary. Adding a second +//! one here would recreate the problem: two configs to keep in sync, two +//! places to get an escape check wrong, and no way for an operator to say +//! "this tree is off limits" once. +//! +//! So every read, write, stat and git invocation below is a bus call into +//! `shell`, and the failures it returns are surfaced verbatim — an S215 +//! jail-escape from `shell` must read as a jail-escape to the caller, not as +//! an `editor` error. + +use std::sync::Arc; + +use iii_sdk::channels::{ChannelReader, StreamChannelRef}; +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::IIIClient; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// Result of a `shell::exec` call, narrowed to what the git parsers need. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ExecOutcome { + #[serde(default)] + pub exit_code: Option, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, + #[serde(default)] + pub timed_out: bool, + #[serde(default)] + pub stdout_truncated: bool, +} + +/// What `shell::fs::stat` reports about a path. +#[derive(Debug, Clone, Deserialize)] +pub struct FileStat { + pub size: u64, + /// Last-modified time, Unix seconds. The conflict guard compares this and + /// nothing else — content hashing every save would double the read cost + /// for a check that only has to catch "someone else touched this". + pub mtime: i64, +} + +/// A file pulled back through the read channel, with the stat that came with it. +#[derive(Debug, Clone)] +pub struct FileRead { + pub content: String, + pub size: u64, + pub mtime: i64, + /// True when the file was larger than the caller's cap and `content` holds + /// only its first bytes. + pub truncated: bool, +} + +/// Thin, typed front for the two workers `editor` talks to. +#[derive(Clone)] +pub struct Bus { + iii: Arc, + /// Engine WebSocket base, needed to open the read channel `shell::fs::read` + /// hands back. Same string the binary was started with. + ws_url: String, + git_timeout_ms: u64, +} + +impl Bus { + pub fn new(iii: Arc, ws_url: String, git_timeout_ms: u64) -> Self { + Self { + iii, + ws_url, + git_timeout_ms, + } + } + + async fn call( + &self, + function_id: &str, + payload: Value, + timeout_ms: u64, + ) -> Result { + self.iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms: Some(timeout_ms), + }) + .await + } + + /// Run `git ` through `shell::exec`. + /// + /// Returns the outcome even for a non-zero exit: "not a git repository" + /// and "unknown revision" are answers a caller wants to see, not transport + /// failures. Only a bus-level failure is an `Err`. + pub async fn git(&self, args: &[&str], cwd: Option<&str>) -> Result { + let mut payload = json!({ + "command": "git", + "args": args, + "timeout_ms": self.git_timeout_ms, + }); + if let Some(dir) = cwd { + payload["cwd"] = json!(dir); + } + let value = self + .call("shell::exec", payload, self.git_timeout_ms + 5_000) + .await?; + serde_json::from_value(value) + .map_err(|e| Error::Handler(format!("shell::exec returned an unexpected shape: {e}"))) + } + + /// Run git and require success, folding a non-zero exit into a readable error. + pub async fn git_ok(&self, args: &[&str], cwd: Option<&str>) -> Result { + let out = self.git(args, cwd).await?; + if out.timed_out { + return Err(Error::Handler(format!( + "git {} timed out after {}ms", + args.join(" "), + self.git_timeout_ms + ))); + } + if out.exit_code != Some(0) { + let detail = if out.stderr.trim().is_empty() { + out.stdout.trim().to_string() + } else { + out.stderr.trim().to_string() + }; + return Err(Error::Handler(format!( + "git {} failed (exit {}): {}", + args.join(" "), + out.exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".into()), + detail + ))); + } + Ok(out) + } + + pub async fn stat(&self, path: &str) -> Result { + let value = self + .call("shell::fs::stat", json!({ "path": path }), 10_000) + .await?; + serde_json::from_value(value).map_err(|e| { + Error::Handler(format!("shell::fs::stat returned an unexpected shape: {e}")) + }) + } + + /// Read a file's text through the channel `shell::fs::read` returns. + /// + /// The size check runs on the stat that comes back with the channel ref, + /// before a byte is pulled, so an oversized file costs one round trip + /// rather than streaming megabytes we intend to throw away. + pub async fn read(&self, path: &str, max_bytes: usize) -> Result { + let value = self + .call("shell::fs::read", json!({ "path": path }), 30_000) + .await?; + + let size = value.get("size").and_then(Value::as_u64).unwrap_or(0); + let mtime = value.get("mtime").and_then(Value::as_i64).unwrap_or(0); + let channel_ref: StreamChannelRef = value + .get("content") + .cloned() + .ok_or_else(|| Error::Handler("shell::fs::read returned no content channel".into())) + .and_then(|v| { + serde_json::from_value(v).map_err(|e| { + Error::Handler(format!("shell::fs::read content is not a channel ref: {e}")) + }) + })?; + + let reader = ChannelReader::new(&self.ws_url, &channel_ref); + let bytes = reader.read_all().await?; + let _ = reader.close().await; + + let truncated = bytes.len() > max_bytes; + let slice = if truncated { + &bytes[..max_bytes] + } else { + &bytes[..] + }; + + // A truncated read can land mid-codepoint; cut back to the last valid + // boundary instead of failing the whole open. + let content = match std::str::from_utf8(slice) { + Ok(s) => s.to_string(), + Err(e) if truncated => String::from_utf8_lossy(&slice[..e.valid_up_to()]).into_owned(), + Err(_) => { + return Err(Error::Handler(format!( + "{path} is not valid UTF-8 text; editor::open reads text files only" + ))) + } + }; + + Ok(FileRead { + content, + size, + mtime, + truncated, + }) + } + + /// Recursive directory listing — the shell worker's own walk. + /// + /// Browsing is not reimplemented here: `coder::tree` already applies the + /// noise-folder excludes, honours the jail, and bounds the traversal. This + /// is a pass-through so the tree and the buffers come from one place. + pub async fn tree(&self, path: &str, max_depth: u32) -> Result { + self.call( + "coder::tree", + json!({ + "path": path, + "max_depth": max_depth, + "per_folder_limit": 500, + }), + 30_000, + ) + .await + } + + /// Move or rename a path. The buffer remap that must follow is the + /// caller's job — see `workspace::Session::remap`. + pub async fn mv(&self, src: &str, dst: &str) -> Result<(), Error> { + self.call("shell::fs::mv", json!({ "src": src, "dst": dst }), 30_000) + .await + .map(|_| ()) + } + + pub async fn mkdir(&self, path: &str) -> Result<(), Error> { + self.call( + "shell::fs::mkdir", + json!({ "path": path, "parents": true }), + 30_000, + ) + .await + .map(|_| ()) + } + + pub async fn rm(&self, path: &str, recursive: bool) -> Result<(), Error> { + self.call( + "shell::fs::rm", + json!({ "path": path, "recursive": recursive }), + 30_000, + ) + .await + .map(|_| ()) + } + + /// Content search — shell's own recursive grep, not a second walk. + pub async fn grep( + &self, + path: &str, + pattern: &str, + ignore_case: bool, + include_glob: &[String], + max_matches: u64, + ) -> Result { + self.call( + "shell::fs::grep", + json!({ + "path": path, + "pattern": pattern, + "recursive": true, + "ignore_case": ignore_case, + "include_glob": include_glob, + "max_matches": max_matches, + }), + 60_000, + ) + .await + } + + /// Read one key from the `state` worker. `None` when unset. + pub async fn state_get(&self, key: &str) -> Result, Error> { + let value = self + .call( + "state::get", + json!({ "scope": crate::workspace::SCOPE, "key": key }), + 10_000, + ) + .await?; + Ok(if value.is_null() { None } else { Some(value) }) + } + + pub async fn state_set(&self, key: &str, value: Value) -> Result<(), Error> { + self.call( + "state::set", + json!({ "scope": crate::workspace::SCOPE, "key": key, "value": value }), + 10_000, + ) + .await + .map(|_| ()) + } + + pub async fn write(&self, path: &str, content: &str) -> Result<(), Error> { + self.call( + "shell::fs::write", + json!({ "path": path, "content": content }), + 30_000, + ) + .await + .map(|_| ()) + } +} diff --git a/editor/src/config.rs b/editor/src/config.rs new file mode 100644 index 000000000..5c18a5592 --- /dev/null +++ b/editor/src/config.rs @@ -0,0 +1,210 @@ +//! Operator-facing limits, owned by the `configuration` worker. +//! +//! Every field is a bound, not a behaviour switch: this worker has no jail, no +//! roots and no denylist of its own because it opens no files — reads, writes +//! and process spawns all go through `shell`, so `shell`'s config is the +//! security surface. What is left to tune here is how much work a single call +//! may cost, and all of it hot-reloads. +//! +//! There is deliberately no committed `config.yaml`: the configuration worker +//! is the source of truth after first boot, and these defaults are what gets +//! seeded when nothing is stored yet. + +use anyhow::Result; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize, Serialize, JsonSchema, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +#[schemars(example = "example_config")] +pub struct WorkerConfig { + /// Largest text, in bytes, either side of a diff may be before + /// `editor::diff` refuses it and answers `truncated: true`. + #[serde(default = "default_max_diff_bytes")] + pub max_diff_bytes: usize, + + /// Unchanged lines kept around each hunk when a caller does not say. + #[serde(default = "default_diff_context_lines")] + pub diff_context_lines: usize, + + /// Default number of rows `editor::find` returns. + #[serde(default = "default_find_limit")] + pub find_limit: usize, + + /// Upper bound on paths scanned per `editor::find` call. A workspace larger + /// than this is ranked on the first N paths and the response says so, + /// rather than the call growing unboundedly. + #[serde(default = "default_max_find_candidates")] + pub max_find_candidates: usize, + + /// Largest file `editor::open` will pull through the read channel. + #[serde(default = "default_max_file_bytes")] + pub max_file_bytes: usize, + + /// Matching lines `editor::search` collects before it stops. + #[serde(default = "default_search_max_matches")] + pub search_max_matches: u64, + + /// Timeout handed to `shell::exec` for each git invocation. + #[serde(default = "default_git_timeout_ms")] + pub git_timeout_ms: u64, +} + +fn default_max_diff_bytes() -> usize { + 2_000_000 +} +fn default_diff_context_lines() -> usize { + 3 +} +fn default_find_limit() -> usize { + 50 +} +fn default_max_find_candidates() -> usize { + 50_000 +} +fn default_max_file_bytes() -> usize { + 2_000_000 +} +fn default_search_max_matches() -> u64 { + 2_000 +} +fn default_git_timeout_ms() -> u64 { + 15_000 +} + +fn example_config() -> WorkerConfig { + WorkerConfig::default() +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + max_diff_bytes: default_max_diff_bytes(), + diff_context_lines: default_diff_context_lines(), + find_limit: default_find_limit(), + max_find_candidates: default_max_find_candidates(), + max_file_bytes: default_max_file_bytes(), + search_max_matches: default_search_max_matches(), + git_timeout_ms: default_git_timeout_ms(), + } + } +} + +impl WorkerConfig { + /// Parse a `--config` seed. `${NAME}` is expanded against the process env + /// here, because a seed file has not been through the configuration worker. + pub fn from_yaml(text: &str) -> Result { + let expanded = expand_env(text); + Ok(serde_yml::from_str(&expanded)?) + } + + pub fn from_file(path: &str) -> Result { + Self::from_yaml(&std::fs::read_to_string(path)?) + } + + /// Parse a value the configuration worker already env-expanded. Do **not** + /// re-expand: a literal `${…}` in a stored value is intentional by then. + pub fn from_json(value: &Value) -> std::result::Result { + serde_json::from_value(value.clone()) + .map_err(|e| format!("invalid editor configuration: {e}")) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + /// JSON Schema published to the configuration worker, with the shipped + /// defaults attached as the example the console renders. + pub fn json_schema() -> Value { + serde_json::to_value(schemars::schema_for!(WorkerConfig)).unwrap_or(Value::Null) + } +} + +/// `${NAME}` → env value, empty when unset. Deliberately minimal: the seed path +/// is a convenience, and the configuration worker owns the richer +/// `${VAR:default}` form. +fn expand_env(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let name = &after[..end]; + out.push_str(&std::env::var(name).unwrap_or_default()); + rest = &after[end + 1..]; + } + None => { + out.push_str(&rest[start..]); + return out; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_from_empty_yaml() { + assert_eq!( + WorkerConfig::from_yaml("{}").unwrap(), + WorkerConfig::default() + ); + } + + #[test] + fn custom_yaml_overrides_one_field_and_defaults_the_rest() { + let cfg = WorkerConfig::from_yaml("find_limit: 5").unwrap(); + assert_eq!(cfg.find_limit, 5); + assert_eq!(cfg.diff_context_lines, default_diff_context_lines()); + } + + /// A typo in a seed file must fail loudly rather than silently defaulting + /// the field the operator thought they were setting. + #[test] + fn unknown_fields_are_rejected() { + assert!(WorkerConfig::from_yaml("find_limt: 5").is_err()); + } + + #[test] + fn json_round_trips_through_the_configuration_shape() { + let cfg = WorkerConfig { + find_limit: 7, + ..WorkerConfig::default() + }; + assert_eq!(WorkerConfig::from_json(&cfg.to_json()).unwrap(), cfg); + } + + #[test] + fn from_json_reports_a_readable_error() { + let err = WorkerConfig::from_json(&serde_json::json!({ "find_limit": "lots" })) + .expect_err("a string is not a limit"); + assert!(err.contains("invalid editor configuration")); + } + + #[test] + fn schema_carries_the_defaults_as_an_example() { + let schema = WorkerConfig::json_schema(); + let rendered = serde_json::to_string(&schema).unwrap(); + assert!(rendered.contains("max_diff_bytes")); + assert!(rendered.contains("examples"), "console renders the example"); + } + + #[test] + fn seed_expands_environment_variables() { + std::env::set_var("EDITOR_TEST_LIMIT", "9"); + let cfg = WorkerConfig::from_yaml("find_limit: ${EDITOR_TEST_LIMIT}").unwrap(); + assert_eq!(cfg.find_limit, 9); + } + + #[test] + fn an_unterminated_placeholder_is_left_alone() { + assert_eq!(expand_env("a ${UNCLOSED"), "a ${UNCLOSED"); + } +} diff --git a/editor/src/configuration.rs b/editor/src/configuration.rs new file mode 100644 index 000000000..8c1ff8b8b --- /dev/null +++ b/editor/src/configuration.rs @@ -0,0 +1,207 @@ +//! Integration with the `configuration` worker — register the schema, fetch the +//! authoritative value at boot, and hot-reload it when it changes. Mirrors +//! [`context-manager`](../../context-manager/src/configuration.rs). +//! +//! Every field here is a tuning knob: caps, limits and a timeout. None of them +//! is structural — there is no adapter to rebuild and no trigger to re-bind, +//! because this worker owns no resources of its own (files go through `shell`, +//! the workspace record through `state`). So a reload is a snapshot swap, and +//! every handler reads the live snapshot per call rather than capturing one at +//! registration. Nothing requires a restart. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; + +use crate::config::WorkerConfig; + +/// Hot-swappable config snapshot shared with every handler. The +/// `Arc>>` shape lets a handler take a `read().await` +/// and clone the inner `Arc` out (a refcount bump) without holding the lock +/// across its work, while [`apply_config`] replaces the inner `Arc` under the +/// write lock. +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "editor"; +const CONFIG_FN_ID: &str = "editor::on-config-change"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; +/// Base backoff between configuration RPC retries; multiplied by the attempt +/// number for a linear backoff (250ms, 500ms, …). +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; + +pub fn cell(cfg: WorkerConfig) -> ConfigCell { + Arc::new(RwLock::new(Arc::new(cfg))) +} + +/// Register the `editor` configuration schema. +/// +/// When `seed` is present its value is installed as `initial_value`; otherwise +/// the built-in default is seeded only when nothing is stored yet, so calling +/// this on every boot never overwrites an operator's edit. +pub async fn register_config(iii: &IIIClient, seed: Option<&WorkerConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Editor", + "description": "Editor workspace limits: diff size and context, file-finder and \ + content-search caps, the largest file an open will pull back, and \ + the per-git-invocation timeout. Nothing here grants access — the \ + filesystem boundary is the shell worker's jail.", + "schema": WorkerConfig::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default_value(iii).await? { + payload["initial_value"] = WorkerConfig::default().to_json(); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live configuration (env-expanded by the configuration worker — +/// `from_json` does NOT re-expand). +pub async fn fetch_config(iii: &IIIClient) -> Result { + let value = try_get_config_value(iii) + .await? + .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found"))?; + if value.is_null() { + tracing::info!("no stored configuration; using built-in defaults"); + return Ok(WorkerConfig::default()); + } + WorkerConfig::from_json(&value) +} + +async fn should_seed_default_value(iii: &IIIClient) -> Result { + match try_get_config_value(iii).await? { + None => Ok(true), + Some(value) if value.is_null() => Ok(true), + Some(_) => Ok(false), + } +} + +/// `Ok(None)` when the entry does not exist. The engine's missing-entry codes +/// vary in case (`function_not_found`, `NOT_FOUND`), so match case-insensitively. +async fn try_get_config_value(iii: &IIIClient) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +async fn trigger_with_retry( + iii: &IIIClient, + function_id: &str, + payload: Value, +) -> Result { + let mut last = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(CONFIG_TIMEOUT_MS), + }) + .await + { + Ok(value) => return Ok(value), + Err(e) => { + last = e.to_string(); + // A missing entry is an answer, not a transient failure — do + // not spend the whole retry budget on it. + if last.to_ascii_uppercase().contains("NOT_FOUND") { + break; + } + if attempt < CONFIG_RETRIES { + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * attempt as u64, + )) + .await; + } + } + } + } + Err(last) +} + +/// Swap the snapshot. Handlers read it per call, so the next invocation of +/// every function sees the new values. +pub async fn apply_config(cell: &ConfigCell, cfg: WorkerConfig) { + *cell.write().await = Arc::new(cfg); +} + +/// Internal `editor::on-config-change` payload. The handler re-fetches the +/// authoritative value, so this carries only the (advisory) id; a struct rather +/// than a `Value` keeps the request schema concrete. +#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] +pub struct OnConfigChangeEvent { + /// Configuration id that changed (advisory; the handler re-fetches). + #[serde(default)] + pub id: Option, +} + +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct OnConfigChangeResponse { + pub ok: bool, +} + +/// Register the internal config-change handler and bind a `configuration` +/// trigger. Registered here rather than in `functions::register_all` so it +/// stays off the public `catalog()`. +pub fn register_config_trigger(iii: &IIIClient, cell: ConfigCell) -> Result<(), Error> { + let cell_for_fn = cell.clone(); + let engine = iii.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_event: OnConfigChangeEvent| { + let cell = cell_for_fn.clone(); + let engine = engine.clone(); + async move { + on_config_change(&engine, &cell).await; + Ok::(OnConfigChangeResponse { ok: true }) + } + }) + .description( + "Internal: hot-reload the editor's limits from the authoritative configuration \ + when it changes.", + ) + .metadata(json!({ "internal": true, "trace_hidden": true })), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Reload from the AUTHORITATIVE configuration. +/// +/// The trigger payload is deliberately ignored: `editor::on-config-change` is a +/// bus function, so trusting a caller-supplied value would let anyone inject +/// limits without updating persisted state. A failed fetch keeps the previous +/// snapshot (last-good) rather than falling back to defaults, which would +/// silently widen every cap. +async fn on_config_change(iii: &IIIClient, cell: &ConfigCell) { + match fetch_config(iii).await { + Ok(cfg) => { + apply_config(cell, cfg).await; + tracing::info!("editor configuration reloaded"); + } + Err(e) => tracing::error!( + error = %e, + "config-change: failed to fetch authoritative configuration; keeping previous config" + ), + } +} diff --git a/editor/src/diff.rs b/editor/src/diff.rs new file mode 100644 index 000000000..104ce9177 --- /dev/null +++ b/editor/src/diff.rs @@ -0,0 +1,251 @@ +//! Unified line diff between two in-memory texts. +//! +//! This is the one piece of the worker that touches nothing else: strings in, +//! patch out. Callers already hold both sides — an agent has the text it is +//! about to write, the console page has the buffer and what `shell::fs::read` +//! returned — so `editor::diff` never needs a path, a jail, or a filesystem. +//! +//! Hunks are returned as structured ranges *alongside* the rendered patch. The +//! patch is for humans and for `git apply`; the ranges are what a gutter or a +//! review UI paints, and computing them here means no consumer has to re-parse +//! `@@` headers to find out which lines moved. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use similar::{ChangeTag, TextDiff}; + +/// One `@@` block: the line ranges it covers on each side, plus the counts a +/// gutter needs without re-reading the patch body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Hunk { + /// First line of the hunk on the "before" side, 1-based. `0` when the + /// before side is empty (pure addition), matching unified-diff convention. + pub old_start: u32, + /// Number of "before" lines the hunk spans. + pub old_lines: u32, + /// First line of the hunk on the "after" side, 1-based. + pub new_start: u32, + /// Number of "after" lines the hunk spans. + pub new_lines: u32, + /// Lines added within this hunk. + pub added: u32, + /// Lines removed within this hunk. + pub removed: u32, +} + +/// A rendered patch plus the structured view of the same edits. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct DiffResult { + /// Unified diff. Empty when the two sides are identical. + pub patch: String, + /// One entry per `@@` block, in file order. + pub hunks: Vec, + /// Total lines added across every hunk. + pub added: u32, + /// Total lines removed across every hunk. + pub removed: u32, + /// True when `before` and `after` are byte-identical. + pub identical: bool, + /// True when either side exceeded `max_bytes` and the diff was skipped. + /// `patch` and `hunks` are empty in that case — a caller that ignores this + /// flag would read "no changes" from a file that was simply too big. + pub truncated: bool, +} + +/// Diff `before` against `after`. +/// +/// `context` is the number of unchanged lines kept around each hunk (the `-U` +/// of `git diff`). `path` labels the `---`/`+++` header; `None` renders the +/// git-style `a/`-less placeholder so the patch is still valid to read. +/// +/// Refuses inputs over `max_bytes` rather than spending unbounded time and +/// memory on a minified bundle or a checked-in binary blob. +pub fn diff( + before: &str, + after: &str, + path: Option<&str>, + context: usize, + max_bytes: usize, +) -> DiffResult { + if before == after { + return DiffResult { + patch: String::new(), + hunks: Vec::new(), + added: 0, + removed: 0, + identical: true, + truncated: false, + }; + } + + if before.len() > max_bytes || after.len() > max_bytes { + return DiffResult { + patch: String::new(), + hunks: Vec::new(), + added: 0, + removed: 0, + identical: false, + truncated: true, + }; + } + + let text_diff = TextDiff::from_lines(before, after); + let label = path.unwrap_or("file"); + + let mut patch = format!("--- a/{label}\n+++ b/{label}\n"); + let mut hunks = Vec::new(); + let mut added = 0u32; + let mut removed = 0u32; + + for group in text_diff.grouped_ops(context).iter() { + // `grouped_ops` yields the ops of one hunk; the first and last carry + // its bounds on both sides. + let (Some(first), Some(last)) = (group.first(), group.last()) else { + continue; + }; + let old_range = first.old_range().start..last.old_range().end; + let new_range = first.new_range().start..last.new_range().end; + + let mut hunk_added = 0u32; + let mut hunk_removed = 0u32; + let mut body = String::new(); + + for op in group { + for change in text_diff.iter_changes(op) { + let sign = match change.tag() { + ChangeTag::Delete => { + hunk_removed += 1; + '-' + } + ChangeTag::Insert => { + hunk_added += 1; + '+' + } + ChangeTag::Equal => ' ', + }; + body.push(sign); + body.push_str(change.value()); + // A final line with no trailing newline must not run into the + // next patch line, or the patch stops being applyable. + if !change.value().ends_with('\n') { + body.push('\n'); + body.push_str("\\ No newline at end of file\n"); + } + } + } + + let old_len = (old_range.end - old_range.start) as u32; + let new_len = (new_range.end - new_range.start) as u32; + // Unified diff numbers lines from 1, except an empty range on one + // side, which is written as start 0. + let old_start = if old_len == 0 { + old_range.start as u32 + } else { + old_range.start as u32 + 1 + }; + let new_start = if new_len == 0 { + new_range.start as u32 + } else { + new_range.start as u32 + 1 + }; + + patch.push_str(&format!( + "@@ -{old_start},{old_len} +{new_start},{new_len} @@\n" + )); + patch.push_str(&body); + + added += hunk_added; + removed += hunk_removed; + hunks.push(Hunk { + old_start, + old_lines: old_len, + new_start, + new_lines: new_len, + added: hunk_added, + removed: hunk_removed, + }); + } + + DiffResult { + patch, + hunks, + added, + removed, + identical: false, + truncated: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAX: usize = 1 << 20; + + #[test] + fn identical_inputs_report_no_change() { + let r = diff("a\nb\n", "a\nb\n", None, 3, MAX); + assert!(r.identical); + assert!(r.patch.is_empty()); + assert!(r.hunks.is_empty()); + assert_eq!((r.added, r.removed), (0, 0)); + } + + #[test] + fn single_line_change_counts_both_sides() { + let r = diff("a\nb\nc\n", "a\nB\nc\n", Some("x.txt"), 3, MAX); + assert!(!r.identical); + assert_eq!((r.added, r.removed), (1, 1)); + assert_eq!(r.hunks.len(), 1); + assert!(r.patch.contains("--- a/x.txt")); + assert!(r.patch.contains("+++ b/x.txt")); + assert!(r.patch.contains("-b\n")); + assert!(r.patch.contains("+B\n")); + } + + #[test] + fn pure_insertion_into_empty_file_starts_at_zero() { + let r = diff("", "hello\n", None, 3, MAX); + assert_eq!(r.hunks.len(), 1); + assert_eq!(r.hunks[0].old_start, 0); + assert_eq!(r.hunks[0].old_lines, 0); + assert_eq!(r.hunks[0].new_start, 1); + assert_eq!((r.added, r.removed), (1, 0)); + } + + #[test] + fn distant_edits_produce_separate_hunks() { + let before: String = (0..40).map(|i| format!("line {i}\n")).collect(); + let mut after: Vec = (0..40).map(|i| format!("line {i}\n")).collect(); + after[1] = "CHANGED near top\n".to_string(); + after[38] = "CHANGED near bottom\n".to_string(); + let r = diff(&before, &after.concat(), None, 3, MAX); + assert_eq!(r.hunks.len(), 2, "context 3 must not merge distant edits"); + assert_eq!((r.added, r.removed), (2, 2)); + } + + #[test] + fn missing_trailing_newline_is_marked() { + let r = diff("a\nb", "a\nc", None, 3, MAX); + assert!(r.patch.contains("\\ No newline at end of file")); + } + + #[test] + fn oversized_input_is_refused_not_silently_empty() { + let big = "x\n".repeat(100); + let r = diff(&big, "y\n", None, 3, 8); + assert!(r.truncated); + assert!(!r.identical, "truncated must never look like 'no changes'"); + assert!(r.patch.is_empty()); + } + + #[test] + fn context_zero_yields_tight_hunks() { + let before = "a\nb\nc\nd\ne\n"; + let after = "a\nb\nX\nd\ne\n"; + let r = diff(before, after, None, 0, MAX); + assert_eq!(r.hunks.len(), 1); + assert_eq!(r.hunks[0].old_lines, 1); + assert_eq!(r.hunks[0].new_lines, 1); + } +} diff --git a/editor/src/functions/mod.rs b/editor/src/functions/mod.rs new file mode 100644 index 000000000..e4e1971f1 --- /dev/null +++ b/editor/src/functions/mod.rs @@ -0,0 +1,1217 @@ +//! Registration and handler bodies for the `editor::*` surface. +//! +//! The shape follows the editor this is modelled on: a **workspace** — a root, +//! the buffers open against it, and which folders are expanded — rather than a +//! bag of stateless verbs. That workspace lives in the `state` worker, so it is +//! a fact on the bus instead of something a browser tab happens to remember. +//! That is what lets an agent and a person share one editor: the agent opens a +//! file and it appears in your tabs, you open one and the agent can see it. +//! +//! Everything a filesystem already does is delegated. `shell` reads, writes, +//! moves and lists (`coder::tree` is its walk, not a reimplementation here); +//! `state` persists. What is left is the editor's own model: diffing, ranking +//! paths, refusing a stale write, and keeping open buffers correct when a +//! folder moves underneath them. +//! +//! A git repository is an overlay. Branch and marks appear when the root +//! happens to be a repo; nothing else changes when it is not. + +pub mod types; + +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::{IIIClient, RegisterFunction}; + +use crate::bus::Bus; +use crate::config::WorkerConfig; +use crate::configuration::ConfigCell; +use crate::diff::DiffResult; +use crate::git::{parse_hunk_headers, parse_status, StatusReport}; +use crate::workspace::{session_key, Buffer, Session, ACTIVE_ROOT_KEY}; +use crate::{diff, fuzzy, lang, tree}; + +use types::*; + +pub const DESC_WORKSPACE_OPEN: &str = + "Set the directory the editor works in. Any folder will do — a git repository is an \ + overlay, not a requirement. Returns the buffers and expanded folders remembered for it."; +pub const DESC_WORKSPACE_GET: &str = + "The active workspace: its root, the files open against it, and which folders are \ + expanded. Shared by every surface, so this is what the agent and the console both see."; +pub const DESC_TREE: &str = + "List a folder in the workspace, with the expansion state the workspace remembers. \ + The walk, the noise-folder excludes and the jail are the shell worker's."; +pub const DESC_OPEN: &str = + "Read a text file and record it as an open buffer, with the metadata needed to write \ + it back safely: its language id and the mtime to hand to editor::save."; +pub const DESC_SAVE: &str = + "Write a file, refusing the write when it changed underneath since the editor::open it \ + started from. On refusal the divergence comes back as a patch."; +pub const DESC_BUFFERS_LIST: &str = "Files currently open in the workspace."; +pub const DESC_BUFFERS_CLOSE: &str = "Close one open buffer. The file on disk is untouched."; +pub const DESC_MOVE: &str = + "Move or rename a path and rewrite every open buffer and expanded folder at or under \ + it. Moving a folder with `shell::fs::mv` alone leaves buffers pointing at the old \ + location, which silently recreates it on the next save."; +pub const DESC_FIND: &str = + "Fuzzy file finder over the workspace, ranked the way an editor's open-file palette \ + ranks. Candidates come from git when the root is a repository and from the folder \ + listing when it is not."; +pub const DESC_DIFF: &str = + "Unified diff between two texts. Pure: nothing is read from disk. Use it to show what \ + an edit will do before writing it, or to explain what a write did."; +pub const DESC_GIT_STATUS: &str = + "Working-tree status as typed rows: branch, upstream, ahead/behind, and one entry per \ + changed path. Fails when the root is not a repository — that is an absent overlay, \ + not a broken workspace."; +pub const DESC_CREATE: &str = + "Create a file or folder in the workspace, with parents as needed. A file may be \ + seeded with content."; +pub const DESC_DELETE: &str = + "Remove a path and close any buffer it held. An open buffer for a deleted file would \ + recreate it on the next save."; +pub const DESC_SEARCH: &str = + "Search file contents across the workspace, grouped by file — the shell worker's \ + recursive grep, shaped into what a results panel renders."; +pub const DESC_GIT_COMMIT: &str = + "Stage and commit. Returns the new SHA, or committed:false when there was nothing \ + staged."; +pub const DESC_GIT_SYNC: &str = + "Fetch, fast-forward pull, or push. Pull is --ff-only on purpose: a merge under open \ + buffers is how an editor ends up showing a conflicted tree it never asked for."; +pub const DESC_GIT_STASH: &str = "Stash the working tree, or pop the most recent stash."; +pub const DESC_GIT_UNDO_COMMIT: &str = + "Undo the last commit, keeping its changes staged (reset --soft HEAD~1). Returns the \ + SHA and message that were undone."; +pub const DESC_GIT_SHOW: &str = + "Read a file's contents at a revision (HEAD by default). Pair it with the working copy \ + to render a real side-by-side or unified diff, rather than parsing a patch."; +pub const DESC_GIT_HUNKS: &str = + "What changed in one file: the rendered patch plus its line ranges. Compares the \ + working tree against the index, the index against HEAD, or the working tree against \ + HEAD — so it shows an edit made by anything, including an agent that never called \ + this worker."; + +/// `cfg` is the live snapshot cell, not a captured value: the configuration +/// worker can change these limits at any time, and a handler that closed over +/// an `Arc` at registration would keep serving the boot-time +/// numbers forever. +pub fn register_all(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + register_workspace_open(iii, bus); + register_workspace_get(iii, bus); + register_tree(iii, bus); + register_open(iii, cfg, bus); + register_save(iii, cfg, bus); + register_buffers_list(iii, bus); + register_buffers_close(iii, bus); + register_move(iii, bus); + register_create(iii, bus); + register_delete(iii, bus); + register_find(iii, cfg, bus); + register_search(iii, cfg, bus); + register_diff(iii, cfg); + register_git_status(iii, bus); + register_git_hunks(iii, cfg, bus); + register_git_show(iii, bus); + register_git_commit(iii, bus); + register_git_sync(iii, bus); + register_git_stash(iii, bus); + register_git_undo_commit(iii, bus); + tracing::info!("all functions registered"); +} + +/// Every registered function, in registration order, for the golden test. +pub fn function_ids() -> Vec<&'static str> { + vec![ + "editor::workspace::open", + "editor::workspace::get", + "editor::tree", + "editor::open", + "editor::save", + "editor::buffers::list", + "editor::buffers::close", + "editor::move", + "editor::create", + "editor::delete", + "editor::find", + "editor::search", + "editor::diff", + "editor::git::status", + "editor::git::hunks", + "editor::git::show", + "editor::git::commit", + "editor::git::sync", + "editor::git::stash", + "editor::git::undo-commit", + ] +} + +/// Response type of `editor::git::status`, named for the catalog. +pub type GitStatusOutput = StatusReport; + +// ---------------------------------------------------------------- workspace + +/// The active root, or `.` — shell's own working directory — when nothing has +/// been opened yet. Defaulting rather than erroring means every function works +/// on a fresh install without a setup step. +async fn active_root(bus: &Bus) -> String { + bus.state_get(ACTIVE_ROOT_KEY) + .await + .ok() + .flatten() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| ".".to_string()) +} + +/// A corrupt or absent session reads as an empty one. Losing the tab list is a +/// far better failure than refusing to open the editor. +async fn load_session(bus: &Bus, root: &str) -> Session { + bus.state_get(&session_key(root)) + .await + .ok() + .flatten() + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default() +} + +async fn save_session(bus: &Bus, root: &str, session: &Session) -> Result<(), Error> { + let value = serde_json::to_value(session) + .map_err(|e| Error::Handler(format!("session failed to serialize: {e}")))?; + bus.state_set(&session_key(root), value).await +} + +fn view(root: String, session: Session) -> WorkspaceView { + WorkspaceView { + root, + buffers: session.buffers, + expanded: session.expanded, + } +} + +fn register_workspace_open(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::workspace::open", + RegisterFunction::new_async(move |req: WorkspaceOpenInput| { + let bus = bus.clone(); + async move { + // Prove the folder is reachable before recording it, so a typo + // fails here rather than as a confusing error on every later call. + bus.tree(&req.root, 1).await?; + bus.state_set(ACTIVE_ROOT_KEY, serde_json::json!(req.root)) + .await?; + let session = load_session(&bus, &req.root).await; + Ok::<_, Error>(view(req.root, session)) + } + }) + .description(DESC_WORKSPACE_OPEN), + ); +} + +fn register_workspace_get(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::workspace::get", + RegisterFunction::new_async(move |_req: EmptyInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + let session = load_session(&bus, &root).await; + Ok::<_, Error>(view(root, session)) + } + }) + .description(DESC_WORKSPACE_GET), + ); +} + +fn register_tree(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::tree", + RegisterFunction::new_async(move |req: TreeInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + let target = match req.path.as_deref() { + None | Some("") | Some(".") => root.clone(), + Some(rel) if rel.starts_with('/') => rel.to_string(), + Some(rel) => format!("{root}/{rel}"), + }; + let listing = bus.tree(&target, req.max_depth.unwrap_or(4)).await?; + + let mut session = load_session(&bus, &root).await; + let before = session.expanded.clone(); + for path in &req.expand { + session.expand(path); + } + for path in &req.collapse { + session.collapse(path); + } + if session.expanded != before { + save_session(&bus, &root, &session).await?; + } + Ok::<_, Error>(TreeOutput { + path: listing + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or(&target) + .to_string(), + root, + tree: listing, + expanded: session.expanded, + }) + } + }) + .description(DESC_TREE), + ); +} + +// ------------------------------------------------------------------ buffers + +fn register_buffers_list(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::buffers::list", + RegisterFunction::new_async(move |_req: EmptyInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + let session = load_session(&bus, &root).await; + Ok::<_, Error>(BuffersOutput { + root, + buffers: session.buffers, + }) + } + }) + .description(DESC_BUFFERS_LIST), + ); +} + +fn register_buffers_close(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::buffers::close", + RegisterFunction::new_async(move |req: BufferCloseInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + let mut session = load_session(&bus, &root).await; + let closed = session.close(&req.path); + if closed { + save_session(&bus, &root, &session).await?; + } + Ok::<_, Error>(BufferCloseOutput { + closed, + root, + buffers: session.buffers, + }) + } + }) + .description(DESC_BUFFERS_CLOSE), + ); +} + +fn register_move(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::move", + RegisterFunction::new_async(move |req: MoveInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + bus.mv(&joined(&root, &req.from), &joined(&root, &req.to)) + .await?; + + // The remap runs only after the move succeeded: rewriting + // buffers for a move that failed would point them at a path + // that does not exist. + let mut session = load_session(&bus, &root).await; + let remapped = session.remap(&req.from, &req.to) as u32; + if remapped > 0 { + save_session(&bus, &root, &session).await?; + } + Ok::<_, Error>(MoveOutput { + from: req.from, + to: req.to, + remapped, + root, + buffers: session.buffers, + }) + } + }) + .description(DESC_MOVE), + ); +} + +/// Join a root-relative path onto the root, leaving absolute paths alone. +fn joined(root: &str, rel: &str) -> String { + if rel.starts_with('/') || root == "." { + rel.to_string() + } else { + format!("{root}/{rel}") + } +} + +// -------------------------------------------------------------- open / save + +fn register_open(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + "editor::open", + RegisterFunction::new_async(move |req: OpenInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + async move { + let cfg = cfg.read().await.clone(); + let root = active_root(&bus).await; + let file = bus + .read(&joined(&root, &req.path), cfg.max_file_bytes) + .await?; + let language = lang::for_path(&req.path).to_string(); + + // A truncated read must not become a buffer: saving it back + // would delete the rest of the file, and the guard for that + // belongs at the door rather than in every caller. + if !file.truncated { + let mut session = load_session(&bus, &root).await; + session.upsert(Buffer { + path: req.path.clone(), + mtime: file.mtime, + language: language.clone(), + }); + save_session(&bus, &root, &session).await?; + } + + Ok::<_, Error>(OpenOutput { + path: req.path, + content: file.content, + language, + size: file.size, + mtime: file.mtime, + truncated: file.truncated, + }) + } + }) + .description(DESC_OPEN), + ); +} + +fn register_save(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + "editor::save", + RegisterFunction::new_async(move |req: SaveInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + async move { + let cfg = cfg.read().await.clone(); + save(&bus, &cfg, req).await + } + }) + .description(DESC_SAVE), + ); +} + +/// The conflict guard, split out so its branches stay readable. +/// +/// Ordering matters: the pre-write read happens *before* the write, because it +/// is what the response's `added`/`removed` describe. Doing it after would diff +/// the file against itself and report every save as a no-op. +async fn save(bus: &Bus, cfg: &WorkerConfig, req: SaveInput) -> Result { + let root = active_root(bus).await; + let full = joined(&root, &req.path); + let existing = bus.stat(&full).await.ok(); + + if let (Some(expected), Some(stat)) = (req.expected_mtime, existing.as_ref()) { + if stat.mtime != expected { + let on_disk = bus.read(&full, cfg.max_file_bytes).await?; + let divergence = diff::diff( + &on_disk.content, + &req.content, + Some(&req.path), + cfg.diff_context_lines, + cfg.max_diff_bytes, + ); + return Ok(SaveOutput { + path: req.path, + saved: false, + conflict: true, + mtime: stat.mtime, + disk_mtime: Some(stat.mtime), + conflict_patch: Some(divergence.patch), + added: divergence.added, + removed: divergence.removed, + created: false, + }); + } + } + + // An expected mtime for a file that is not there means it was deleted under + // the editor. Recreating it silently would undo that deletion. + if req.expected_mtime.is_some() && existing.is_none() { + return Err(Error::Handler(format!( + "{} no longer exists; re-open it before saving", + req.path + ))); + } + + let previous: Option = match existing.as_ref() { + Some(_) => { + let before = bus.read(&full, cfg.max_file_bytes).await?; + if before.truncated { + return Err(Error::Handler(format!( + "{} is larger than max_file_bytes; saving would truncate it", + req.path + ))); + } + Some(diff::diff( + &before.content, + &req.content, + Some(&req.path), + cfg.diff_context_lines, + cfg.max_diff_bytes, + )) + } + None => None, + }; + + bus.write(&full, &req.content).await?; + let after = bus.stat(&full).await?; + + // The buffer's mtime is now stale everywhere else; refreshing it here is + // what lets a second surface save next without a spurious conflict. + let mut session = load_session(bus, &root).await; + session.upsert(Buffer { + path: req.path.clone(), + mtime: after.mtime, + language: lang::for_path(&req.path).to_string(), + }); + save_session(bus, &root, &session).await?; + + Ok(SaveOutput { + // A file that did not exist is entirely added — reporting 0 there would + // make "created a 400-line file" indistinguishable from a no-op. + added: previous + .as_ref() + .map(|d| d.added) + .unwrap_or_else(|| req.content.lines().count() as u32), + removed: previous.as_ref().map(|d| d.removed).unwrap_or(0), + path: req.path, + saved: true, + conflict: false, + mtime: after.mtime, + disk_mtime: None, + conflict_patch: None, + created: existing.is_none(), + }) +} + +// -------------------------------------------------------------------- find + +fn register_find(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + "editor::find", + RegisterFunction::new_async(move |req: FindInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + async move { + let cfg = cfg.read().await.clone(); + let root = active_root(&bus).await; + let (candidates, from_git) = candidates(&bus, &root, &req, &cfg).await?; + let refs: Vec<&str> = candidates.iter().map(String::as_str).collect(); + let limit = req.limit.map(|l| l as usize).unwrap_or(cfg.find_limit); + let ranked = fuzzy::rank(&req.query, &refs, limit); + + Ok::<_, Error>(FindOutput { + matches: ranked + .into_iter() + .map(|(path, m)| FindMatch { + path: path.to_string(), + score: m.score, + positions: m.positions.into_iter().map(|p| p as u32).collect(), + }) + .collect(), + scanned: refs.len() as u32, + truncated: refs.len() >= cfg.max_find_candidates, + from_git, + }) + } + }) + .description(DESC_FIND), + ); +} + +/// Paths to rank, and whether git supplied them. +/// +/// git's listing is preferred where it exists — it already honours +/// `.gitignore`, which is most of what makes a picker usable in a real +/// checkout. A plain folder is not a degraded case, just a different source: +/// the workspace listing, which shell already excludes noise from. +async fn candidates( + bus: &Bus, + root: &str, + req: &FindInput, + cfg: &WorkerConfig, +) -> Result<(Vec, bool), Error> { + let mut args = vec!["ls-files", "--cached"]; + if req.include_untracked { + args.push("--others"); + args.push("--exclude-standard"); + } + let git = bus.git(&args, Some(root)).await?; + if git.exit_code == Some(0) { + let mut paths: Vec = git + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + paths.truncate(cfg.max_find_candidates); + return Ok((paths, true)); + } + + let listing = bus.tree(root, 12).await?; + Ok((tree::file_paths(&listing, cfg.max_find_candidates), false)) +} + +// -------------------------------------------------------------------- diff + +fn register_diff(iii: &Arc, cfg: &ConfigCell) { + let cfg = cfg.clone(); + iii.register_function( + "editor::diff", + RegisterFunction::new_async(move |req: DiffInput| { + let cfg = cfg.clone(); + async move { + let cfg = cfg.read().await.clone(); + let context = req + .context_lines + .map(|c| c as usize) + .unwrap_or(cfg.diff_context_lines); + Ok::<_, Error>(diff::diff( + &req.before, + &req.after, + req.path.as_deref(), + context, + cfg.max_diff_bytes, + )) + } + }) + .description(DESC_DIFF), + ); +} + +// --------------------------------------------------------------------- git + +fn register_git_status(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::status", + RegisterFunction::new_async(move |req: GitStatusInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + let out = bus + .git_ok( + &[ + "status", + "--porcelain=v2", + "--branch", + "--untracked-files=all", + ], + Some(&cwd), + ) + .await?; + Ok::<_, Error>(parse_status(&out.stdout)) + } + }) + .description(DESC_GIT_STATUS), + ); +} + +fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + "editor::git::hunks", + RegisterFunction::new_async(move |req: GitHunksInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + async move { + let cfg = cfg.read().await.clone(); + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + // Context is a knob rather than a constant: a gutter wants + // `-U0` ranges, a person reading the patch wants surrounding + // lines. Defaulting to 3 favours the reader, because the + // ranges are still correct — just wider. + let context = format!("-U{}", req.context_lines.unwrap_or(3)); + let mut args = vec!["diff", &context, "--no-color"]; + match req.against { + Against::Worktree => {} + Against::Index => args.push("--cached"), + Against::Head => args.push("HEAD"), + } + args.push("--"); + args.push(&req.path); + + let out = bus.git_ok(&args, Some(&cwd)).await?; + let hunks = parse_hunk_headers(&out.stdout); + + // An untracked file produces no diff at all, which is + // indistinguishable from "unchanged" unless we ask. + let untracked = if hunks.is_empty() { + let probe = bus + .git( + &["ls-files", "--error-unmatch", "--", &req.path], + Some(&cwd), + ) + .await?; + probe.exit_code != Some(0) + } else { + false + }; + + let added = hunks.iter().map(|h| h.added).sum(); + let removed = hunks.iter().map(|h| h.removed).sum(); + // Bounded like every other patch this worker emits: a huge + // diff must not become an unbounded response. + let mut patch = out.stdout; + if patch.len() > cfg.max_diff_bytes { + patch.truncate(cfg.max_diff_bytes); + } + Ok::<_, Error>(GitHunksOutput { + path: req.path, + against: req.against, + hunks, + added, + removed, + untracked, + patch, + }) + } + }) + .description(DESC_GIT_HUNKS), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn joined_leaves_absolute_paths_alone() { + assert_eq!(joined("/repo", "/etc/hosts"), "/etc/hosts"); + } + + #[test] + fn joined_uses_shell_cwd_when_no_root_is_set() { + assert_eq!(joined(".", "src/main.rs"), "src/main.rs"); + } + + #[test] + fn joined_prefixes_the_root() { + assert_eq!(joined("/repo", "src/main.rs"), "/repo/src/main.rs"); + } + + #[test] + fn function_ids_are_unique_and_namespaced() { + let ids = function_ids(); + let mut seen = std::collections::HashSet::new(); + for id in &ids { + assert!( + id.starts_with("editor::"), + "{id} is outside the worker namespace" + ); + assert!( + !id.contains('_'), + "{id} uses snake_case; ids are kebab-case" + ); + assert!(seen.insert(*id), "{id} is registered twice"); + } + } + + /// The catalog is what ships to the registry; a function registered but + /// left out of it would have no published schema. + #[test] + fn every_registered_function_is_in_the_catalog() { + let cataloged: Vec<&str> = crate::surface::catalog() + .iter() + .map(|s| s.function_id) + .collect(); + assert_eq!(function_ids(), cataloged); + } +} + +// ------------------------------------------------------------------ file ops + +fn register_create(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::create", + RegisterFunction::new_async(move |req: CreateInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + let full = joined(&root, &req.path); + match req.kind { + EntryKind::Folder => bus.mkdir(&full).await?, + EntryKind::File => { + // The parent may not exist yet, and shell::fs::write + // does not create one, so ask for it first. + if let Some((parent, _)) = req.path.rsplit_once('/') { + bus.mkdir(&joined(&root, parent)).await?; + } + bus.write(&full, req.content.as_deref().unwrap_or("")) + .await?; + } + } + Ok::<_, Error>(CreateOutput { + path: req.path, + kind: req.kind, + created: true, + }) + } + }) + .description(DESC_CREATE), + ); +} + +fn register_delete(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::delete", + RegisterFunction::new_async(move |req: DeleteInput| { + let bus = bus.clone(); + async move { + let root = active_root(&bus).await; + bus.rm(&joined(&root, &req.path), req.recursive).await?; + + // Close buffers for anything that is now gone. A folder delete + // takes every buffer beneath it, not just an exact match — a + // buffer left open would recreate the file on its next save. + let mut session = load_session(&bus, &root).await; + let prefix = format!("{}/", req.path); + let closed: Vec = session + .buffers + .iter() + .map(|b| b.path.clone()) + .filter(|p| p == &req.path || p.starts_with(&prefix)) + .collect(); + for path in &closed { + session.close(path); + } + session.collapse(&req.path); + if !closed.is_empty() { + save_session(&bus, &root, &session).await?; + } + + Ok::<_, Error>(DeleteOutput { + path: req.path, + deleted: true, + buffers_closed: closed, + buffers: session.buffers, + }) + } + }) + .description(DESC_DELETE), + ); +} + +// -------------------------------------------------------------------- search + +fn register_search(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + "editor::search", + RegisterFunction::new_async(move |req: SearchInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + async move { + let cfg = cfg.read().await.clone(); + let root = active_root(&bus).await; + let max = req.max_matches.unwrap_or(cfg.search_max_matches); + let raw = bus + .grep(&root, &req.pattern, req.ignore_case, &req.include_glob, max) + .await?; + Ok::<_, Error>(group_matches(&raw, &root)) + } + }) + .description(DESC_SEARCH), + ); +} + +/// Group shell's flat match list by file, in first-match order. +/// +/// Order is preserved rather than sorted: a results panel reads top-down, and +/// re-ordering between keystrokes makes the list impossible to follow. +/// +/// Paths come back root-relative. shell answers with absolute ones, and every +/// other function on this worker speaks root-relative — a caller should never +/// have to know which of the two a given response used. +fn group_matches(raw: &serde_json::Value, root: &str) -> SearchOutput { + let mut files: Vec = Vec::new(); + let mut total = 0u32; + + let matches = raw + .get("matches") + .and_then(|v| v.as_array()) + .map(Vec::as_slice) + .unwrap_or(&[]); + + for m in matches { + let (Some(path), Some(line)) = ( + m.get("path").and_then(|v| v.as_str()), + m.get("line").and_then(|v| v.as_u64()), + ) else { + continue; + }; + let text = m + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let path = relative_to(path, root); + total += 1; + match files.iter_mut().find(|f| f.path == path) { + Some(file) => file.hits.push(SearchHit { line, text }), + None => files.push(SearchFile { + path, + hits: vec![SearchHit { line, text }], + }), + } + } + + SearchOutput { + files, + total, + truncated: raw + .get("truncated") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + } +} + +// --------------------------------------------------------------- git writes + +/// Strip the workspace root from an absolute path, leaving anything that does +/// not sit under it untouched. +fn relative_to(path: &str, root: &str) -> String { + if root == "." { + return path.to_string(); + } + let trimmed = root.trim_end_matches('/'); + path.strip_prefix(trimmed) + .map(|rest| rest.trim_start_matches('/')) + .filter(|rest| !rest.is_empty()) + .unwrap_or(path) + .to_string() +} + +/// Ahead/behind straight from git, for the sync response. +async fn ahead_behind(bus: &Bus, cwd: &str) -> (u32, u32) { + let Ok(out) = bus + .git( + &[ + "status", + "--porcelain=v2", + "--branch", + "--untracked-files=no", + ], + Some(cwd), + ) + .await + else { + return (0, 0); + }; + let report = parse_status(&out.stdout); + (report.ahead, report.behind) +} + +/// Both streams, trimmed. git reports progress on stderr even on success, so +/// taking stdout alone renders a successful push as an empty response. +fn summarize(out: &crate::bus::ExecOutcome) -> String { + let mut text = out.stdout.trim().to_string(); + let err = out.stderr.trim(); + if !err.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(err); + } + text +} + +fn register_git_commit(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::commit", + RegisterFunction::new_async(move |req: GitCommitInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + if req.stage_all { + bus.git_ok(&["add", "-A"], Some(&cwd)).await?; + } + let out = bus.git(&["commit", "-m", &req.message], Some(&cwd)).await?; + + // "nothing to commit" exits non-zero but is not a failure: a + // commit button pressed on a clean tree should say so, not raise. + if out.exit_code != Some(0) { + let text = summarize(&out); + if text.contains("nothing to commit") || text.contains("no changes added") { + return Ok::<_, Error>(GitCommitOutput { + committed: false, + sha: None, + summary: text, + }); + } + return Err(Error::Handler(format!("git commit failed: {text}"))); + } + + let sha = bus + .git(&["rev-parse", "HEAD"], Some(&cwd)) + .await + .ok() + .filter(|o| o.exit_code == Some(0)) + .map(|o| o.stdout.trim().to_string()); + + Ok::<_, Error>(GitCommitOutput { + committed: true, + sha, + summary: summarize(&out), + }) + } + }) + .description(DESC_GIT_COMMIT), + ); +} + +fn register_git_sync(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::sync", + RegisterFunction::new_async(move |req: GitSyncInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + let args: Vec<&str> = match req.action { + SyncAction::Fetch => vec!["fetch"], + SyncAction::Pull => vec!["pull", "--ff-only"], + SyncAction::Push => vec!["push"], + }; + let out = bus.git(&args, Some(&cwd)).await?; + let (ahead, behind) = ahead_behind(&bus, &cwd).await; + Ok::<_, Error>(GitSyncOutput { + action: req.action, + ok: out.exit_code == Some(0), + summary: summarize(&out), + ahead, + behind, + }) + } + }) + .description(DESC_GIT_SYNC), + ); +} + +fn register_git_stash(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::stash", + RegisterFunction::new_async(move |req: GitStashInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + let args: Vec<&str> = match req.action { + StashAction::Push => vec!["stash", "push"], + StashAction::Pop => vec!["stash", "pop"], + }; + let out = bus.git(&args, Some(&cwd)).await?; + Ok::<_, Error>(GitStashOutput { + action: req.action, + ok: out.exit_code == Some(0), + summary: summarize(&out), + }) + } + }) + .description(DESC_GIT_STASH), + ); +} + +fn register_git_undo_commit(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::undo-commit", + RegisterFunction::new_async(move |req: GitUndoCommitInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + // Read the commit BEFORE undoing it: afterwards HEAD points at + // its parent and neither the sha nor the message is recoverable + // for the response. + let sha = bus.git_ok(&["rev-parse", "HEAD"], Some(&cwd)).await?; + let message = bus + .git_ok(&["log", "-1", "--pretty=%B"], Some(&cwd)) + .await?; + bus.git_ok(&["reset", "--soft", "HEAD~1"], Some(&cwd)) + .await?; + Ok::<_, Error>(GitUndoCommitOutput { + undone_sha: sha.stdout.trim().to_string(), + message: message.stdout.trim().to_string(), + }) + } + }) + .description(DESC_GIT_UNDO_COMMIT), + ); +} + +#[cfg(test)] +mod parity_tests { + use super::*; + use serde_json::json; + + #[test] + fn matches_group_by_file_in_first_match_order() { + let raw = json!({ + "matches": [ + { "path": "b.rs", "line": 3, "content": "hit one" }, + { "path": "a.rs", "line": 9, "content": "hit two" }, + { "path": "b.rs", "line": 41, "content": "hit three" } + ], + "truncated": false + }); + let out = group_matches(&raw, "."); + assert_eq!( + out.files + .iter() + .map(|f| f.path.as_str()) + .collect::>(), + vec!["b.rs", "a.rs"], + "file order follows first match, never sorted" + ); + assert_eq!(out.files[0].hits.len(), 2); + assert_eq!(out.files[0].hits[1].line, 41); + assert_eq!(out.total, 3); + assert!(!out.truncated); + } + + #[test] + fn truncation_is_carried_through() { + let raw = json!({ "matches": [], "truncated": true }); + assert!(group_matches(&raw, ".").truncated); + } + + #[test] + fn a_malformed_match_is_skipped_not_fatal() { + let raw = json!({ + "matches": [ { "path": "a.rs" }, { "path": "a.rs", "line": 2 } ] + }); + let out = group_matches(&raw, "."); + assert_eq!(out.total, 1, "the entry without a line is dropped"); + assert_eq!(out.files[0].hits[0].text, "", "missing content reads empty"); + } + + #[test] + fn search_paths_come_back_root_relative() { + let raw = json!({ + "matches": [ { "path": "/repo/src/a.rs", "line": 1, "content": "x" } ] + }); + assert_eq!(group_matches(&raw, "/repo").files[0].path, "src/a.rs"); + } + + #[test] + fn a_path_outside_the_root_is_left_alone() { + let raw = json!({ + "matches": [ { "path": "/elsewhere/a.rs", "line": 1, "content": "x" } ] + }); + assert_eq!( + group_matches(&raw, "/repo").files[0].path, + "/elsewhere/a.rs" + ); + } + + #[test] + fn summarize_keeps_stderr_when_stdout_is_empty() { + let out = crate::bus::ExecOutcome { + exit_code: Some(0), + stdout: String::new(), + stderr: "Everything up-to-date".to_string(), + timed_out: false, + stdout_truncated: false, + }; + assert_eq!(summarize(&out), "Everything up-to-date"); + } + + #[test] + fn summarize_joins_both_streams() { + let out = crate::bus::ExecOutcome { + exit_code: Some(0), + stdout: "out".to_string(), + stderr: "err".to_string(), + timed_out: false, + stdout_truncated: false, + }; + assert_eq!(summarize(&out), "out\nerr"); + } +} + +fn register_git_show(iii: &Arc, bus: &Arc) { + let bus = bus.clone(); + iii.register_function( + "editor::git::show", + RegisterFunction::new_async(move |req: GitShowInput| { + let bus = bus.clone(); + async move { + let cwd = match req.cwd { + Some(dir) => dir, + None => active_root(&bus).await, + }; + let rev = req.rev.unwrap_or_else(|| "HEAD".to_string()); + let spec = format!("{rev}:{}", req.path); + let out = bus.git(&["show", &spec], Some(&cwd)).await?; + + // A path absent at that revision is an answer, not a failure: + // it is exactly what a newly added file looks like. + if out.exit_code != Some(0) { + return Ok::<_, Error>(GitShowOutput { + path: req.path, + rev, + content: String::new(), + exists: false, + }); + } + Ok::<_, Error>(GitShowOutput { + path: req.path, + rev, + content: out.stdout, + exists: true, + }) + } + }) + .description(DESC_GIT_SHOW), + ); +} diff --git a/editor/src/functions/types.rs b/editor/src/functions/types.rs new file mode 100644 index 000000000..fe4366840 --- /dev/null +++ b/editor/src/functions/types.rs @@ -0,0 +1,526 @@ +//! Wire types for every registered function. +//! +//! Kept in one module because the six functions share a small vocabulary +//! (`Hunk`, path, mtime) and splitting them across six files would mean six +//! copies of the same doc comments explaining what an mtime is for. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::diff::Hunk; +use crate::workspace::Buffer; + +// ----------------------------------------------------- editor::workspace::* + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WorkspaceOpenInput { + /// Directory to work in. Jail-relative when shell's `fs.host_roots` are + /// set, else absolute. A plain folder is enough — a git repository is an + /// overlay, never a requirement. + pub root: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct EmptyInput {} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct WorkspaceView { + /// The active root every other path in this response is relative to. + pub root: String, + /// Files currently open against this root, shared by every surface. + pub buffers: Vec, + /// Folders expanded in the tree, root-relative. + pub expanded: Vec, +} + +// ---------------------------------------------------------------- editor::tree + +/// Create a file or a folder. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum EntryKind { + File, + Folder, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateInput { + /// Root-relative path to create. Missing parent folders are created. + pub path: String, + /// What to create. Defaults to a file. + #[serde(default = "default_file_kind")] + pub kind: EntryKind, + /// Initial contents for a file. Ignored for a folder. + #[serde(default)] + pub content: Option, +} + +fn default_file_kind() -> EntryKind { + EntryKind::File +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct CreateOutput { + pub path: String, + pub kind: EntryKind, + /// Always true on success; the call errors rather than reporting false. + pub created: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeleteInput { + /// Root-relative path to remove. + pub path: String, + /// Required to remove a non-empty folder. + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct DeleteOutput { + pub path: String, + pub deleted: bool, + /// Buffers closed because their file is gone. Leaving them open would let + /// the next save recreate a file the user just deleted. + pub buffers_closed: Vec, + pub buffers: Vec, +} + +// -------------------------------------------------------------- editor::search + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SearchInput { + /// Rust regex matched against each line. + pub pattern: String, + /// Match case-insensitively. + #[serde(default)] + pub ignore_case: bool, + /// Glob filters restricting which files are searched, e.g. `["**/*.rs"]`. + #[serde(default)] + pub include_glob: Vec, + /// Stop after this many matching lines. Defaults to the worker's + /// `search_max_matches`. + #[serde(default)] + pub max_matches: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SearchHit { + /// 1-based line number. + pub line: u64, + /// The matching line, as shell returned it. + pub text: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SearchFile { + pub path: String, + pub hits: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SearchOutput { + /// Matches grouped by file, in first-match order — the shape a result + /// panel renders, rather than a flat list every caller has to group. + pub files: Vec, + /// Total matching lines across every file. + pub total: u32, + /// True when the search stopped at `max_matches`. + pub truncated: bool, +} + +// ------------------------------------------------------------- editor::git::* + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GitCommitInput { + /// Commit message. Passed as a single `-m` argument. + pub message: String, + /// Stage every change first (`git add -A`). On by default, matching what + /// an editor's commit command does; pass false to commit only the index. + #[serde(default = "default_true")] + pub stage_all: bool, + /// Repository to run in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitCommitOutput { + /// False when there was nothing staged to commit. + pub committed: bool, + /// Full SHA of the new commit, when one was made. + pub sha: Option, + /// git's own summary line, verbatim. + pub summary: String, +} + +/// Which remote operation to run. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SyncAction { + Fetch, + /// Fast-forward only. A pull that would merge fails instead, so this can + /// never produce a conflicted tree under open buffers. + Pull, + Push, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GitSyncInput { + /// Which remote operation to run. + pub action: SyncAction, + /// Repository to run in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitSyncOutput { + pub action: SyncAction, + pub ok: bool, + /// git's output, trimmed. Both streams: git reports progress on stderr. + pub summary: String, + /// Ahead/behind after the operation, so a caller does not need a second + /// round trip to find out whether it changed anything. + pub ahead: u32, + pub behind: u32, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum StashAction { + Push, + Pop, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GitStashInput { + /// Stash the working tree, or restore the most recent stash. + pub action: StashAction, + /// Repository to run in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitStashOutput { + pub action: StashAction, + pub ok: bool, + pub summary: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GitUndoCommitInput { + /// Repository to run in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitUndoCommitOutput { + /// SHA that was undone. + pub undone_sha: String, + /// Its message, so a caller can put it straight back in a commit box. + pub message: String, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct TreeInput { + /// Folder to list, relative to the workspace root. Defaults to the root. + #[serde(default)] + pub path: Option, + /// Levels to descend. Defaults to 4 — deep enough to navigate, shallow + /// enough that one call does not walk a whole monorepo. + #[serde(default)] + pub max_depth: Option, + /// Root-relative folders to mark expanded before listing. Expansion is + /// part of the shared workspace, so it survives a reload and both surfaces + /// agree on it. + #[serde(default)] + pub expand: Vec, + /// Root-relative folders to collapse. Collapsing takes its descendants + /// with it. + #[serde(default)] + pub collapse: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TreeOutput { + pub root: String, + /// Canonical absolute path of the listed folder, as shell resolved it. + pub path: String, + /// The listing exactly as the shell worker returned it (nested + /// `{name, kind, size, mtime, children}` nodes). Passed through rather + /// than re-modelled so this worker does not pin shell's response shape. + pub tree: serde_json::Value, + /// Folders the workspace has expanded, root-relative. + pub expanded: Vec, +} + +// ------------------------------------------------------------ editor::buffers::* + +#[derive(Debug, Serialize, JsonSchema)] +pub struct BuffersOutput { + pub root: String, + pub buffers: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BufferCloseInput { + /// Root-relative path of the buffer to close. + pub path: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct BufferCloseOutput { + /// False when nothing was open at that path. + pub closed: bool, + pub root: String, + pub buffers: Vec, +} + +// ---------------------------------------------------------------- editor::move + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MoveInput { + /// Existing root-relative path. + pub from: String, + /// Destination root-relative path. + pub to: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct MoveOutput { + pub from: String, + pub to: String, + /// Open buffers and expanded folders rewritten to the new location. A + /// folder move rewrites everything beneath it, which is the whole reason + /// moves go through this function instead of `shell::fs::mv` directly. + pub remapped: u32, + pub root: String, + pub buffers: Vec, +} + +// ---------------------------------------------------------------- editor::diff + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiffInput { + /// The text as it was. + pub before: String, + /// The text as it will be. + pub after: String, + /// Path used to label the patch header. Nothing is read from disk — this + /// is presentation only. + #[serde(default)] + pub path: Option, + /// Unchanged lines kept around each hunk (`-U` of `git diff`). Defaults to + /// the worker's `diff_context_lines`. + #[serde(default)] + pub context_lines: Option, +} + +// ----------------------------------------------------------- editor::git::status + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct GitStatusInput { + /// Directory to run git in. Defaults to the workspace root. Confined by + /// shell's jail exactly like any other `shell::exec` call. + #[serde(default)] + pub cwd: Option, +} + +// ------------------------------------------------------------ editor::git::hunks + +/// Which copy of the file the working tree is compared against. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Against { + /// Unstaged edits: working tree vs the index. + #[default] + Worktree, + /// Staged edits: index vs HEAD. + Index, + /// Everything since the last commit: working tree vs HEAD. + Head, +} + +impl Against { + pub fn as_str(self) -> &'static str { + match self { + Against::Worktree => "worktree", + Against::Index => "index", + Against::Head => "head", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GitHunksInput { + /// Repository-relative path to inspect. + pub path: String, + /// Directory to run git in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, + /// Which comparison to make. Defaults to `worktree`. + #[serde(default)] + pub against: Against, + /// Unchanged lines kept around each hunk in `patch`. Defaults to 3, which + /// reads well. Pass 0 for ranges that match a gutter exactly — with + /// context, a hunk's reported range widens to include it. + #[serde(default)] + pub context_lines: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitHunksOutput { + pub path: String, + /// Echo of the comparison performed, so a cached response is self-describing. + pub against: Against, + /// Changed ranges, in file order. Empty when the file matches. + pub hunks: Vec, + pub added: u32, + pub removed: u32, + /// True when git reports the path as untracked, in which case there is + /// nothing to compare against and `hunks` is empty. + pub untracked: bool, + /// The rendered unified patch, for showing a person what changed. Empty + /// when there is no difference, and capped by `max_diff_bytes` — a caller + /// that only wants the ranges can ignore it. + pub patch: String, +} + +// --------------------------------------------------------- editor::git::show + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GitShowInput { + /// Root-relative path. + pub path: String, + /// Revision to read the file at. Defaults to `HEAD`. + #[serde(default)] + pub rev: Option, + /// Repository to run in. Defaults to the workspace root. + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct GitShowOutput { + pub path: String, + pub rev: String, + /// The file's contents at that revision. Empty when the path did not exist + /// there — which is what `exists: false` distinguishes from an empty file. + pub content: String, + /// False when the path is absent at that revision (a file being added). + pub exists: bool, +} + +// ---------------------------------------------------------------- editor::find + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindInput { + /// Fuzzy query. Matched as a subsequence against every tracked path, with + /// basename and word-boundary hits ranked highest. Empty returns the first + /// `limit` paths unranked. + pub query: String, + /// Rows to return. Defaults to the worker's `find_limit`. + #[serde(default)] + pub limit: Option, + /// Include files git does not track yet (still honouring `.gitignore`). + /// On by default — a file the agent just created is exactly the one you + /// are looking for. Ignored outside a repository, where the workspace + /// listing is the source of candidates. + #[serde(default = "default_true")] + pub include_untracked: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct FindMatch { + pub path: String, + /// Higher is better. Comparable only within one response. + pub score: i32, + /// Byte offsets into `path` that matched, in order — enough to highlight + /// the match without re-running the matcher in the UI. + pub positions: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct FindOutput { + pub matches: Vec, + /// Paths considered. Compare against `truncated` to know whether the whole + /// repo was ranked. + pub scanned: u32, + /// True when the workspace held more paths than `max_find_candidates` and + /// only the first of them were ranked. + pub truncated: bool, + /// True when candidates came from git's listing (so `.gitignore` was + /// honoured), false when they came from the folder walk. + pub from_git: bool, +} + +// ---------------------------------------------------------------- editor::open + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OpenInput { + /// Jail-relative when `shell`'s `fs.host_roots` are set, else absolute — + /// the same path vocabulary as `shell::fs::read`. + pub path: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct OpenOutput { + pub path: String, + /// File contents as text. Binary files are refused rather than mangled. + pub content: String, + /// Monaco language id for the path, e.g. `rust`, `typescript`, `plaintext`. + pub language: String, + pub size: u64, + /// Last-modified time, Unix seconds. Pass it back as `expected_mtime` on + /// `editor::save` to get the conflict guard. + pub mtime: i64, + /// True when the file exceeded `max_file_bytes` and `content` holds only + /// its beginning. Saving a truncated buffer back would delete the rest of + /// the file, so `editor::save` refuses one. + pub truncated: bool, +} + +// ---------------------------------------------------------------- editor::save + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SaveInput { + pub path: String, + /// Full new contents. This is a whole-file write, not a patch. + pub content: String, + /// The `mtime` from the `editor::open` this edit started from. When it no + /// longer matches the file on disk, the write is refused and the + /// divergence comes back as a patch. Omit only when deliberately + /// overwriting whatever is there. + #[serde(default)] + pub expected_mtime: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SaveOutput { + pub path: String, + /// True when the file was written. + pub saved: bool, + /// True when the write was refused because the file changed underneath. + pub conflict: bool, + /// Last-modified time after the write. Feed it into the next save. + pub mtime: i64, + /// What was on disk when a conflict was detected. + pub disk_mtime: Option, + /// On conflict: a unified diff from the current disk contents to the + /// contents you tried to write, so the divergence is reviewable without a + /// second round trip. + pub conflict_patch: Option, + /// Lines this save added relative to what was on disk before it. + pub added: u32, + /// Lines this save removed. + pub removed: u32, + /// True when the file did not exist and was created. + pub created: bool, +} diff --git a/editor/src/fuzzy.rs b/editor/src/fuzzy.rs new file mode 100644 index 000000000..350c3eb1c --- /dev/null +++ b/editor/src/fuzzy.rs @@ -0,0 +1,188 @@ +//! Fuzzy path matching for `editor::find`. +//! +//! Path-aware rather than generic: a query is matched as a subsequence, but +//! the score is dominated by *where* the characters landed. Matching the +//! basename beats matching a directory, matching after a separator beats +//! matching mid-word, and runs of adjacent characters beat scattered hits. +//! Without that bias, `mod` in a large repo ranks a hundred `src/models/…` +//! directories above the `mod.rs` you meant. +//! +//! Matching is case-insensitive, with a bonus when the case matched exactly — +//! so `App` prefers `App.tsx` over `app.config.js` without ever hiding it. + +/// One scored candidate. `positions` are byte indices into the haystack, in +/// order, so a UI can highlight exactly the characters that matched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Match { + pub score: i32, + pub positions: Vec, +} + +const BONUS_CONSECUTIVE: i32 = 8; +const BONUS_SEPARATOR: i32 = 10; +const BONUS_BASENAME: i32 = 12; +const BONUS_EXACT_CASE: i32 = 2; +const PENALTY_LEADING: i32 = -1; +const PENALTY_LEADING_MAX: i32 = -12; +const PENALTY_UNMATCHED_TAIL: i32 = -1; + +fn is_separator(c: char) -> bool { + matches!(c, '/' | '\\' | '_' | '-' | '.' | ' ') +} + +/// Score `query` against `haystack`, or `None` when `query` is not a +/// subsequence of it. +/// +/// Greedy left-to-right: the first viable match for each query character is +/// taken. That is not the globally optimal alignment, but it is linear and the +/// separator/basename bonuses recover the cases an optimal matcher would win — +/// worth it when the candidate list is every tracked file in a repo. +pub fn score(query: &str, haystack: &str) -> Option { + if query.is_empty() { + return Some(Match { + score: 0, + positions: Vec::new(), + }); + } + + let basename_start = haystack.rfind('/').map(|i| i + 1).unwrap_or(0); + + let hay: Vec<(usize, char)> = haystack.char_indices().collect(); + let mut positions = Vec::with_capacity(query.chars().count()); + let mut total = 0i32; + let mut hay_idx = 0usize; + let mut last_match: Option = None; + + for qc in query.chars() { + let qc_lower = qc.to_ascii_lowercase(); + let mut found = None; + + while hay_idx < hay.len() { + let (byte_idx, hc) = hay[hay_idx]; + if hc.to_ascii_lowercase() == qc_lower { + found = Some((hay_idx, byte_idx, hc)); + break; + } + hay_idx += 1; + } + + let (idx, byte_idx, hc) = found?; + + let mut points = 1; + if hc == qc { + points += BONUS_EXACT_CASE; + } + if byte_idx >= basename_start { + points += BONUS_BASENAME; + } + if last_match == Some(idx.wrapping_sub(1)) { + points += BONUS_CONSECUTIVE; + } else if idx == 0 || hay.get(idx - 1).is_some_and(|(_, p)| is_separator(*p)) { + points += BONUS_SEPARATOR; + } + + total += points; + positions.push(byte_idx); + last_match = Some(idx); + hay_idx = idx + 1; + } + + // Characters skipped before the first match, and everything trailing the + // last one, both make the match less about this path. Bounded so a long + // path is not disqualified outright. + let leading = positions.first().copied().unwrap_or(0) as i32; + total += (leading * PENALTY_LEADING).max(PENALTY_LEADING_MAX); + let trailing = haystack + .len() + .saturating_sub(positions.last().copied().unwrap_or(0)) as i32; + total += (trailing * PENALTY_UNMATCHED_TAIL).max(PENALTY_LEADING_MAX); + + Some(Match { + score: total, + positions, + }) +} + +/// Rank `candidates` by [`score`], best first, keeping at most `limit`. +/// +/// Ties break on the shorter path, then alphabetically, so the same query +/// always produces the same ordering — a picker that reshuffles equal-scoring +/// rows between keystrokes is unusable. +pub fn rank<'a>(query: &str, candidates: &[&'a str], limit: usize) -> Vec<(&'a str, Match)> { + let mut scored: Vec<(&str, Match)> = candidates + .iter() + .filter_map(|c| score(query, c).map(|m| (*c, m))) + .collect(); + + scored.sort_by(|a, b| { + b.1.score + .cmp(&a.1.score) + .then_with(|| a.0.len().cmp(&b.0.len())) + .then_with(|| a.0.cmp(b.0)) + }); + scored.truncate(limit); + scored +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_subsequence_does_not_match() { + assert!(score("zzz", "src/main.rs").is_none()); + } + + #[test] + fn empty_query_matches_everything_at_zero() { + let m = score("", "src/main.rs").expect("empty query matches"); + assert_eq!(m.score, 0); + assert!(m.positions.is_empty()); + } + + #[test] + fn positions_point_at_the_matched_characters() { + let m = score("mn", "src/main.rs").expect("subsequence"); + let chars: String = m + .positions + .iter() + .map(|&i| "src/main.rs"[i..].chars().next().unwrap()) + .collect(); + assert_eq!(chars, "mn"); + } + + #[test] + fn basename_beats_directory() { + let ranked = rank("mod", &["src/models/user.rs", "src/tree/mod.rs"], 10); + assert_eq!(ranked[0].0, "src/tree/mod.rs"); + } + + #[test] + fn exact_case_outranks_folded_case() { + let ranked = rank("App", &["src/app.config.js", "src/App.tsx"], 10); + assert_eq!(ranked[0].0, "src/App.tsx"); + } + + #[test] + fn consecutive_run_beats_scattered_hits() { + let ranked = rank("editor", &["e/d/i/t/o/r.rs", "src/editor.rs"], 10); + assert_eq!(ranked[0].0, "src/editor.rs"); + } + + #[test] + fn ranking_is_stable_for_equal_scores() { + let candidates = ["b/x.rs", "a/x.rs"]; + let first = rank("x", &candidates, 10); + let second = rank("x", &candidates, 10); + assert_eq!( + first.iter().map(|r| r.0).collect::>(), + second.iter().map(|r| r.0).collect::>() + ); + } + + #[test] + fn limit_is_respected() { + let candidates = ["a.rs", "ab.rs", "abc.rs", "abcd.rs"]; + assert_eq!(rank("a", &candidates, 2).len(), 2); + } +} diff --git a/editor/src/git.rs b/editor/src/git.rs new file mode 100644 index 000000000..d5a2ca96a --- /dev/null +++ b/editor/src/git.rs @@ -0,0 +1,337 @@ +//! Parsers for the git output `editor` asks `shell::exec` to produce. +//! +//! The worker runs no git itself: `shell::exec` owns the process, the jail, +//! the denylist and the timeout. What lives here is the half that has no +//! business being in a shell worker — turning porcelain text into typed rows a +//! UI or an agent can use without re-implementing the format. +//! +//! Porcelain v2 is parsed rather than v1 because v1 cannot express rename +//! scores or the ahead/behind pair, and its path field is ambiguous once a +//! filename contains a space. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::diff::Hunk; + +/// A single changed path as git sees it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct StatusEntry { + /// Path relative to the repository root. + pub path: String, + /// Status of the staged copy: `modified`, `added`, `deleted`, `renamed`, + /// `copied`, `untracked`, `ignored`, `conflicted`, or `unchanged`. + pub index: String, + /// Status of the working-tree copy, same vocabulary as `index`. + pub worktree: String, + /// True when the staged copy differs from HEAD. + pub staged: bool, + /// Original path for a rename or copy. + pub renamed_from: Option, +} + +/// Branch header plus every changed path. +#[derive(Debug, Clone, Default, Serialize, JsonSchema)] +pub struct StatusReport { + /// Current branch, or `None` on a detached HEAD. + pub branch: Option, + /// Configured upstream, e.g. `origin/main`. + pub upstream: Option, + /// Commits on this branch the upstream does not have. + pub ahead: u32, + /// Commits on the upstream this branch does not have. + pub behind: u32, + /// One row per changed path, in git's order. + pub entries: Vec, + /// True when nothing is modified, staged, or untracked. + pub clean: bool, +} + +fn code_to_word(c: char) -> &'static str { + match c { + 'M' => "modified", + 'A' => "added", + 'D' => "deleted", + 'R' => "renamed", + 'C' => "copied", + 'T' => "typechange", + 'U' => "conflicted", + '.' => "unchanged", + _ => "unknown", + } +} + +/// Parse `git status --porcelain=v2 --branch --untracked-files=all`. +/// +/// Unknown or malformed lines are skipped rather than failing the call: a +/// status view that renders nothing because one exotic line did not parse is +/// worse than one that renders every line it understood. +pub fn parse_status(stdout: &str) -> StatusReport { + let mut report = StatusReport::default(); + + for line in stdout.lines() { + if let Some(rest) = line.strip_prefix("# branch.head ") { + if rest != "(detached)" { + report.branch = Some(rest.to_string()); + } + } else if let Some(rest) = line.strip_prefix("# branch.upstream ") { + report.upstream = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("# branch.ab ") { + // "+2 -3" + for token in rest.split_whitespace() { + let (sign, num) = token.split_at(1); + let n: u32 = num.parse().unwrap_or(0); + match sign { + "+" => report.ahead = n, + "-" => report.behind = n, + _ => {} + } + } + } else if let Some(rest) = line.strip_prefix("1 ") { + // + if let Some(entry) = parse_ordinary(rest) { + report.entries.push(entry); + } + } else if let Some(rest) = line.strip_prefix("2 ") { + // \t + if let Some(entry) = parse_rename(rest) { + report.entries.push(entry); + } + } else if let Some(path) = line.strip_prefix("? ") { + report.entries.push(StatusEntry { + path: path.to_string(), + index: "untracked".to_string(), + worktree: "untracked".to_string(), + staged: false, + renamed_from: None, + }); + } else if let Some(path) = line.strip_prefix("! ") { + report.entries.push(StatusEntry { + path: path.to_string(), + index: "ignored".to_string(), + worktree: "ignored".to_string(), + staged: false, + renamed_from: None, + }); + } else if let Some(rest) = line.strip_prefix("u ") { + // Unmerged. Same leading shape as an ordinary line but with three + // extra mode/hash columns before the path. + let fields: Vec<&str> = rest.splitn(11, ' ').collect(); + if let Some(path) = fields.get(10) { + report.entries.push(StatusEntry { + path: (*path).to_string(), + index: "conflicted".to_string(), + worktree: "conflicted".to_string(), + staged: false, + renamed_from: None, + }); + } + } + } + + report.clean = report + .entries + .iter() + .all(|e| e.index == "ignored" && e.worktree == "ignored"); + report +} + +/// ` ` — path is the remainder, so a +/// filename containing spaces survives. +fn parse_ordinary(rest: &str) -> Option { + let mut fields = rest.splitn(8, ' '); + let xy = fields.next()?; + for _ in 0..6 { + fields.next()?; + } + let path = fields.next()?; + let mut chars = xy.chars(); + let index = code_to_word(chars.next()?); + let worktree = code_to_word(chars.next()?); + Some(StatusEntry { + path: path.to_string(), + staged: index != "unchanged", + index: index.to_string(), + worktree: worktree.to_string(), + renamed_from: None, + }) +} + +/// Rename/copy line: one extra score field, and the path pair is tab-separated. +fn parse_rename(rest: &str) -> Option { + let mut fields = rest.splitn(9, ' '); + let xy = fields.next()?; + for _ in 0..7 { + fields.next()?; + } + let paths = fields.next()?; + let (path, orig) = paths.split_once('\t')?; + let mut chars = xy.chars(); + let index = code_to_word(chars.next()?); + let worktree = code_to_word(chars.next()?); + Some(StatusEntry { + path: path.to_string(), + staged: index != "unchanged", + index: index.to_string(), + worktree: worktree.to_string(), + renamed_from: Some(orig.to_string()), + }) +} + +/// Pull the hunk ranges out of a unified diff. +/// +/// Only `@@` headers are read, so this is cheap on a large diff and works +/// whatever `-U` the caller passed. `@@ -a,b +c,d @@` omits `,b` when the +/// count is 1, which is the case that silently breaks naive parsers. +pub fn parse_hunk_headers(diff_text: &str) -> Vec { + let mut hunks = Vec::new(); + let mut current: Option = None; + + for line in diff_text.lines() { + if let Some(rest) = line.strip_prefix("@@ ") { + if let Some(h) = current.take() { + hunks.push(h); + } + let Some(header) = rest.split(" @@").next() else { + continue; + }; + let mut parts = header.split_whitespace(); + let (Some(old), Some(new)) = (parts.next(), parts.next()) else { + continue; + }; + let Some((old_start, old_lines)) = parse_range(old.trim_start_matches('-')) else { + continue; + }; + let Some((new_start, new_lines)) = parse_range(new.trim_start_matches('+')) else { + continue; + }; + current = Some(Hunk { + old_start, + old_lines, + new_start, + new_lines, + added: 0, + removed: 0, + }); + } else if let Some(h) = current.as_mut() { + if line.starts_with('+') && !line.starts_with("+++") { + h.added += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + h.removed += 1; + } + } + } + if let Some(h) = current.take() { + hunks.push(h); + } + hunks +} + +/// `12,3` or the count-elided `12`. +fn parse_range(s: &str) -> Option<(u32, u32)> { + match s.split_once(',') { + Some((start, len)) => Some((start.parse().ok()?, len.parse().ok()?)), + None => Some((s.parse().ok()?, 1)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn branch_header_and_ahead_behind() { + let out = "# branch.oid abc\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +2 -3\n"; + let r = parse_status(out); + assert_eq!(r.branch.as_deref(), Some("main")); + assert_eq!(r.upstream.as_deref(), Some("origin/main")); + assert_eq!((r.ahead, r.behind), (2, 3)); + assert!(r.clean); + } + + #[test] + fn detached_head_has_no_branch() { + let r = parse_status("# branch.head (detached)\n"); + assert!(r.branch.is_none()); + } + + #[test] + fn ordinary_entry_splits_index_and_worktree() { + let out = "1 .M N... 100644 100644 100644 aaa bbb src/main.rs\n"; + let r = parse_status(out); + assert_eq!(r.entries.len(), 1); + assert_eq!(r.entries[0].path, "src/main.rs"); + assert_eq!(r.entries[0].index, "unchanged"); + assert_eq!(r.entries[0].worktree, "modified"); + assert!(!r.entries[0].staged); + assert!(!r.clean); + } + + #[test] + fn staged_entry_is_flagged() { + let out = "1 M. N... 100644 100644 100644 aaa bbb src/lib.rs\n"; + let r = parse_status(out); + assert!(r.entries[0].staged); + assert_eq!(r.entries[0].index, "modified"); + } + + #[test] + fn path_with_spaces_survives() { + let out = "1 .M N... 100644 100644 100644 aaa bbb my docs/notes v2.md\n"; + let r = parse_status(out); + assert_eq!(r.entries[0].path, "my docs/notes v2.md"); + } + + #[test] + fn rename_carries_the_original_path() { + let out = "2 R. N... 100644 100644 100644 aaa bbb R100 new/name.rs\told/name.rs\n"; + let r = parse_status(out); + assert_eq!(r.entries[0].path, "new/name.rs"); + assert_eq!(r.entries[0].renamed_from.as_deref(), Some("old/name.rs")); + assert_eq!(r.entries[0].index, "renamed"); + } + + #[test] + fn untracked_and_ignored_are_distinguished() { + let r = parse_status("? new.rs\n! target/\n"); + assert_eq!(r.entries[0].index, "untracked"); + assert_eq!(r.entries[1].index, "ignored"); + assert!(!r.clean, "an untracked file is not a clean tree"); + } + + #[test] + fn only_ignored_entries_still_count_as_clean() { + let r = parse_status("! target/\n"); + assert!(r.clean); + } + + #[test] + fn malformed_line_is_skipped_not_fatal() { + let r = parse_status("1 broken\n? real.rs\n"); + assert_eq!(r.entries.len(), 1); + assert_eq!(r.entries[0].path, "real.rs"); + } + + #[test] + fn hunk_headers_parse_with_and_without_counts() { + let d = "@@ -1,3 +1,4 @@\n context\n+added\n@@ -20 +21,2 @@\n-gone\n+new\n+more\n"; + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 2); + assert_eq!((hunks[0].old_start, hunks[0].old_lines), (1, 3)); + assert_eq!(hunks[0].added, 1); + assert_eq!( + (hunks[1].old_start, hunks[1].old_lines), + (20, 1), + "an elided count means exactly one line" + ); + assert_eq!((hunks[1].added, hunks[1].removed), (2, 1)); + } + + #[test] + fn file_header_lines_are_not_counted_as_edits() { + let d = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"; + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 1); + assert_eq!((hunks[0].added, hunks[0].removed), (1, 1)); + } +} diff --git a/editor/src/lang.rs b/editor/src/lang.rs new file mode 100644 index 000000000..eea29f88e --- /dev/null +++ b/editor/src/lang.rs @@ -0,0 +1,98 @@ +//! Path to language id. +//! +//! The ids are Monaco's, because the console page feeds them straight to the +//! shared `CodeEditor`. An unknown id renders as plain text there, so the +//! table only needs to cover what it can name — never guess, and never invent +//! an id Monaco has not heard of. + +/// Language id for `path`, or `"plaintext"` when nothing matches. +/// +/// Whole-filename matches win over extensions: `Dockerfile` and `Makefile` +/// carry no extension, and `.env.local` would otherwise resolve on `local`. +pub fn for_path(path: &str) -> &'static str { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + + match name { + "Dockerfile" | "Containerfile" => return "dockerfile", + "Makefile" | "makefile" | "GNUmakefile" => return "makefile", + "CMakeLists.txt" => return "cmake", + "go.mod" | "go.sum" => return "go", + "Cargo.lock" => return "toml", + _ => {} + } + if name.starts_with(".env") { + return "shell"; + } + if name.starts_with(".gitignore") || name.starts_with(".dockerignore") { + return "plaintext"; + } + + let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); + match ext.to_ascii_lowercase().as_str() { + "rs" => "rust", + "ts" | "mts" | "cts" => "typescript", + "tsx" => "typescript", + "js" | "mjs" | "cjs" => "javascript", + "jsx" => "javascript", + "py" | "pyi" => "python", + "go" => "go", + "rb" => "ruby", + "php" => "php", + "java" => "java", + "kt" | "kts" => "kotlin", + "swift" => "swift", + "scala" | "sc" => "scala", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hpp" | "hh" => "cpp", + "cs" => "csharp", + "dart" => "dart", + "ex" | "exs" => "elixir", + "lua" => "lua", + "sh" | "bash" | "zsh" => "shell", + "sql" => "sql", + "json" | "jsonc" => "json", + "yaml" | "yml" => "yaml", + "toml" => "toml", + "xml" | "svg" => "xml", + "html" | "htm" => "html", + "css" => "css", + "scss" | "sass" => "scss", + "less" => "less", + "md" | "markdown" => "markdown", + "vue" => "html", + "graphql" | "gql" => "graphql", + "ini" | "cfg" | "conf" => "ini", + "diff" | "patch" => "diff", + _ => "plaintext", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_lookup() { + assert_eq!(for_path("src/main.rs"), "rust"); + assert_eq!(for_path("ui/page.tsx"), "typescript"); + assert_eq!(for_path("a/b/c.YAML"), "yaml"); + } + + #[test] + fn whole_filename_beats_extension() { + assert_eq!(for_path("docker/Dockerfile"), "dockerfile"); + assert_eq!(for_path("Makefile"), "makefile"); + } + + #[test] + fn dotfiles_do_not_resolve_on_their_suffix() { + assert_eq!(for_path(".env.local"), "shell"); + assert_eq!(for_path(".gitignore"), "plaintext"); + } + + #[test] + fn unknown_falls_back_to_plaintext() { + assert_eq!(for_path("data.qqq"), "plaintext"); + assert_eq!(for_path("noextension"), "plaintext"); + } +} diff --git a/editor/src/lib.rs b/editor/src/lib.rs new file mode 100644 index 000000000..6d65873e7 --- /dev/null +++ b/editor/src/lib.rs @@ -0,0 +1,21 @@ +//! Editor surface for code, built on top of the workers that already exist. +//! +//! `editor` opens no files and spawns no processes. Reads, writes and git all +//! go through `shell`, which owns the filesystem jail; the console page is the +//! shared `@iii-dev/console-ui` component set. What this worker adds is the +//! part neither of those has: a diff, a set of git marks, a ranked file +//! search, and a save that refuses to clobber. + +pub mod bus; +pub mod config; +pub mod configuration; +pub mod diff; +pub mod functions; +pub mod fuzzy; +pub mod git; +pub mod lang; +pub mod manifest; +pub mod surface; +pub mod tree; +pub mod ui; +pub mod workspace; diff --git a/editor/src/main.rs b/editor/src/main.rs new file mode 100644 index 000000000..3bc32c2f7 --- /dev/null +++ b/editor/src/main.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use clap::Parser; +use editor::{bus::Bus, config, configuration, functions, manifest, ui}; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::{register_worker, InitOptions}; +use std::sync::Arc; + +#[derive(Parser, Debug)] +#[command( + name = "editor", + about = "Editor surface for code — diffs, git marks, fuzzy find, conflict-safe saves" +)] +struct Cli { + /// Optional one-time seed for the configuration worker on first + /// registration. The configuration worker is the source of truth after + /// that, so this never overwrites a stored value. + #[arg(long)] + config: Option, + + /// Engine WebSocket. `III_URL` is what the worker manager injects when the + /// worker runs managed — inside a sandbox the engine is on the VM's + /// gateway, never on the VM's own loopback, so the default is only right + /// for a worker started by hand on the host. + #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] + url: String, + + #[arg(long)] + manifest: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + if cli.manifest { + println!( + "{}", + serde_json::to_string_pretty(&manifest::build_manifest()).unwrap() + ); + return Ok(()); + } + + // A seed that will not parse is a warning, not a failure: the stored + // value (or the built-in default) still applies. + let seed = cli + .config + .as_deref() + .and_then(|path| match config::WorkerConfig::from_file(path) { + Ok(cfg) => Some(cfg), + Err(e) => { + tracing::warn!(error = %e, path, "failed to read config seed; ignoring it"); + None + } + }); + + let iii = register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "editor".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + ); + let iii = Arc::new(iii); + + // The configuration worker is a required boot dependency: without it + // there is no authoritative config, and guessing one silently would mean + // running with limits nobody chose. + configuration::register_config(&iii, seed.as_ref()) + .await + .map_err(|e| anyhow::anyhow!("registering editor configuration: {e}"))?; + let cfg = configuration::fetch_config(&iii) + .await + .map_err(|e| anyhow::anyhow!("loading editor configuration: {e}"))?; + let git_timeout_ms = cfg.git_timeout_ms; + let cfg = configuration::cell(cfg); + + // The bus carries the engine URL because `shell::fs::read` answers with a + // channel reference that has to be dialled separately. + let bus = Arc::new(Bus::new(iii.clone(), cli.url.clone(), git_timeout_ms)); + + functions::register_all(&iii, &cfg, &bus); + ui::register(&iii); + + // Bound last, so the handler closes over fully-built state. + if let Err(e) = configuration::register_config_trigger(&iii, cfg.clone()) { + tracing::warn!(error = %e, "failed to bind the configuration trigger"); + } + + tracing::info!("editor ready, waiting for invocations"); + tokio::signal::ctrl_c().await?; + tracing::info!("editor shutting down"); + iii.shutdown_async().await; + Ok(()) +} diff --git a/editor/src/manifest.rs b/editor/src/manifest.rs new file mode 100644 index 000000000..2187097b2 --- /dev/null +++ b/editor/src/manifest.rs @@ -0,0 +1,55 @@ +use serde::Serialize; + +use crate::config::WorkerConfig; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + // Serialized from the same struct `--config` parses into, so the published + // default_config can never drift from what the worker actually boots with. + let defaults = WorkerConfig::default(); + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: "A shared code workspace — open buffers, a file tree, unified diffs, \ + fuzzy find and conflict-safe saves that an agent and a person see the \ + same view of, plus a console editor page." + .to_string(), + // Serialized from the same struct the configuration worker + // validates, so the published default_config cannot drift. + default_config: defaults.to_json(), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_roundtrip_has_required_fields() { + let m = build_manifest(); + let json = serde_json::to_string_pretty(&m).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], env!("CARGO_PKG_NAME")); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); + assert!(!parsed["description"].as_str().unwrap().is_empty()); + assert!(parsed["default_config"].is_object()); + assert!(!parsed["supported_targets"].as_array().unwrap().is_empty()); + } + + #[test] + fn default_config_mirrors_the_config_struct() { + let m = build_manifest(); + let defaults = WorkerConfig::default(); + assert_eq!(m.default_config["find_limit"], defaults.find_limit); + assert_eq!(m.default_config["git_timeout_ms"], defaults.git_timeout_ms); + } +} diff --git a/editor/src/surface.rs b/editor/src/surface.rs new file mode 100644 index 000000000..56d554ccf --- /dev/null +++ b/editor/src/surface.rs @@ -0,0 +1,75 @@ +//! The agent-facing wire surface, pinned. +//! +//! Every registered function with the description and schemas registration +//! emits. `tests/schemas.rs` snapshots this, so a change to a request type, a +//! response type, or the sentence an agent reads when choosing a function +//! lands as a reviewed golden diff instead of as a silently reshaped API. + +use schemars::schema::RootSchema; +use serde::Serialize; + +use crate::diff::DiffResult; +use crate::functions::types::*; +use crate::functions::{ + GitStatusOutput, DESC_BUFFERS_CLOSE, DESC_BUFFERS_LIST, DESC_CREATE, DESC_DELETE, DESC_DIFF, + DESC_FIND, DESC_GIT_COMMIT, DESC_GIT_HUNKS, DESC_GIT_SHOW, DESC_GIT_STASH, DESC_GIT_STATUS, + DESC_GIT_SYNC, DESC_GIT_UNDO_COMMIT, DESC_MOVE, DESC_OPEN, DESC_SAVE, DESC_SEARCH, DESC_TREE, + DESC_WORKSPACE_GET, DESC_WORKSPACE_OPEN, +}; + +#[derive(Debug, Serialize)] +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request_schema: RootSchema, + pub response_schema: RootSchema, +} + +/// Build a schema with the same generator the SDK uses at registration time, +/// so the snapshot equals what the engine publishes. +fn schema_of() -> RootSchema { + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec( + function_id: &'static str, + description: &'static str, +) -> FunctionSpec { + FunctionSpec { + function_id, + description, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +/// In registration order — `tests/schemas.rs` asserts the match. +pub fn catalog() -> Vec { + vec![ + spec::("editor::workspace::open", DESC_WORKSPACE_OPEN), + spec::("editor::workspace::get", DESC_WORKSPACE_GET), + spec::("editor::tree", DESC_TREE), + spec::("editor::open", DESC_OPEN), + spec::("editor::save", DESC_SAVE), + spec::("editor::buffers::list", DESC_BUFFERS_LIST), + spec::("editor::buffers::close", DESC_BUFFERS_CLOSE), + spec::("editor::move", DESC_MOVE), + spec::("editor::create", DESC_CREATE), + spec::("editor::delete", DESC_DELETE), + spec::("editor::find", DESC_FIND), + spec::("editor::search", DESC_SEARCH), + spec::("editor::diff", DESC_DIFF), + spec::("editor::git::status", DESC_GIT_STATUS), + spec::("editor::git::hunks", DESC_GIT_HUNKS), + spec::("editor::git::show", DESC_GIT_SHOW), + spec::("editor::git::commit", DESC_GIT_COMMIT), + spec::("editor::git::sync", DESC_GIT_SYNC), + spec::("editor::git::stash", DESC_GIT_STASH), + spec::( + "editor::git::undo-commit", + DESC_GIT_UNDO_COMMIT, + ), + ] +} diff --git a/editor/src/tree.rs b/editor/src/tree.rs new file mode 100644 index 000000000..a0160453d --- /dev/null +++ b/editor/src/tree.rs @@ -0,0 +1,158 @@ +//! Flattening the shell worker's tree snapshot into paths. +//! +//! `coder::tree` answers with nested nodes carrying only a `name`; a path is +//! built by joining from the root down. Two callers need that walk — the file +//! picker when there is no git listing to rank, and any surface that wants a +//! flat view — so it lives here once, over `serde_json::Value` rather than a +//! mirrored struct, because the only fields that matter are `name`, `kind` and +//! `children`, and mirroring the rest would couple this worker to shell's +//! response shape for no gain. + +use serde_json::Value; + +/// Every file path in the snapshot, root-relative, in traversal order. +/// +/// Directories are descended but not emitted: this feeds the file picker, and +/// a folder is not something you open. `limit` bounds the walk so a huge tree +/// cannot turn one call into an unbounded allocation. +pub fn file_paths(tree: &Value, limit: usize) -> Vec { + let mut out = Vec::new(); + if let Some(root) = tree.get("root") { + walk(root, "", limit, &mut out); + } + out +} + +fn walk(node: &Value, prefix: &str, limit: usize, out: &mut Vec) { + if out.len() >= limit { + return; + } + let Some(children) = node.get("children").and_then(Value::as_array) else { + return; + }; + for child in children { + if out.len() >= limit { + return; + } + let Some(name) = child.get("name").and_then(Value::as_str) else { + continue; + }; + let path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + if is_dir(child) { + walk(child, &path, limit, out); + } else { + // Anything that is not a directory is openable as far as this is + // concerned; `editor::open` is the one guard that decides whether + // the bytes are actually text. + out.push(path); + } + } +} + +/// shell spells a directory `dir` (its `NodeKind` is lowercase-renamed +/// `File | Dir | Symlink | Other`). `folder` is accepted too so a future +/// rename on that side degrades to a wrong-looking tree rather than to +/// directories being opened as files. +fn is_dir(node: &Value) -> bool { + matches!( + node.get("kind").and_then(Value::as_str), + Some("dir") | Some("folder") + ) || node.get("children").is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample() -> Value { + json!({ + "path": "/repo", + "root": { + "name": "repo", + "kind": "dir", + "children": [ + { "name": "README.md", "kind": "file" }, + { + "name": "src", + "kind": "dir", + "children": [ + { "name": "main.rs", "kind": "file" }, + { + "name": "app", + "kind": "dir", + "children": [{ "name": "mod.rs", "kind": "file" }] + } + ] + } + ] + } + }) + } + + #[test] + fn paths_are_joined_from_the_root_down() { + let paths = file_paths(&sample(), 100); + assert_eq!(paths, vec!["README.md", "src/main.rs", "src/app/mod.rs"]); + } + + #[test] + fn folders_are_descended_but_not_emitted() { + let paths = file_paths(&sample(), 100); + assert!(!paths.iter().any(|p| p == "src" || p == "src/app")); + } + + /// Pins shell's real vocabulary. Reading `dir` as a file is what made + /// the tree open directories instead of expanding them. + #[test] + fn a_dir_node_is_descended_not_emitted() { + let tree = json!({ + "root": { + "name": "r", "kind": "dir", + "children": [{ + "name": "src", "kind": "dir", + "children": [{ "name": "a.rs", "kind": "file" }] + }] + } + }); + assert_eq!(file_paths(&tree, 10), vec!["src/a.rs"]); + } + + #[test] + fn an_empty_dir_contributes_nothing() { + let tree = json!({ + "root": { + "name": "r", "kind": "dir", + "children": [{ "name": "empty", "kind": "dir", "children": [] }] + } + }); + assert!(file_paths(&tree, 10).is_empty()); + } + + #[test] + fn limit_stops_the_walk() { + assert_eq!(file_paths(&sample(), 2).len(), 2); + } + + #[test] + fn an_empty_or_foreign_shape_yields_nothing_rather_than_panicking() { + assert!(file_paths(&json!({}), 10).is_empty()); + assert!(file_paths(&json!({ "root": { "name": "x", "kind": "dir" } }), 10).is_empty()); + assert!(file_paths(&json!({ "root": 5 }), 10).is_empty()); + } + + #[test] + fn a_child_without_a_name_is_skipped_not_fatal() { + let tree = json!({ + "root": { + "name": "r", "kind": "dir", + "children": [ { "kind": "file" }, { "name": "ok.rs", "kind": "file" } ] + } + }); + assert_eq!(file_paths(&tree, 10), vec!["ok.rs"]); + } +} diff --git a/editor/src/ui.rs b/editor/src/ui.rs new file mode 100644 index 000000000..e5ebdf5b4 --- /dev/null +++ b/editor/src/ui.rs @@ -0,0 +1,65 @@ +//! Injectable console UI for the editor worker +//! (authoring SOP: `workers/docs/sops/injectable-console-ui.md`). +//! +//! Ships two assets into any running console: +//! +//! - `editor/page.js` (`console:script`) — the editor page (file tree, tabs, +//! Monaco, diff view, live activity), plus the function-trigger renderers +//! its `setup(host)` registers so `editor::*` calls read as diffs and file +//! cards in chat instead of raw JSON. +//! - `editor/styles.css` (`console:style`) — every rule scoped under +//! `[data-iii-ui="editor"]`. +//! +//! The registration machinery (content function `editor::ui-content`, one +//! Message-path trigger per asset, the `III_EDITOR_UI_WATCH` hot-reload +//! watcher) lives in the shared `iii-console-ui` crate; this module only names +//! the assets and embeds their bytes. + +use std::sync::Arc; + +use iii_console_ui::ConsoleUi; +use iii_sdk::IIIClient; + +pub const PAGE_PATH: &str = "editor/page.js"; +pub const STYLES_PATH: &str = "editor/styles.css"; + +/// Built by `build.rs` (esbuild over `ui/`). +const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); +const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); + +fn console_ui() -> ConsoleUi { + ConsoleUi::new("editor") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the editor worker's console UI. Call after `functions::register_all`. +pub fn register(iii: &Arc) { + console_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = console_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=editor]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="editor"]"#) + || STYLES_CSS.contains("[data-iii-ui=editor]"), + "built styles.css must be scoped under the worker's data-iii-ui attribute" + ); + } +} diff --git a/editor/src/workspace.rs b/editor/src/workspace.rs new file mode 100644 index 000000000..c2d2e28db --- /dev/null +++ b/editor/src/workspace.rs @@ -0,0 +1,244 @@ +//! The workspace: a project root, the buffers open against it, and which +//! folders are expanded. +//! +//! This is the model the whole worker exists to hold, and it deliberately does +//! not live in the browser. A workspace kept in a page is a workspace only that +//! page can see: an agent cannot tell what you have open, a reload loses it, +//! and two surfaces onto the same project disagree. Keeping it in the `state` +//! worker instead makes "what is open" a fact on the bus, which is what lets a +//! human and an agent share one editor rather than two views that happen to +//! read the same disk. +//! +//! Nothing here does I/O. The functions are pure transforms over the record so +//! the rules that are easy to get wrong — above all the path remap on a move — +//! are testable without an engine. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// State scope every key below lives under. +pub const SCOPE: &str = "editor"; +/// Key holding the active root, so a fresh surface can find its way back. +pub const ACTIVE_ROOT_KEY: &str = "active-root"; + +/// Per-project session key. Keyed by root path exactly like the editor this +/// follows: opening a second project must not overwrite the first one's tabs. +pub fn session_key(root: &str) -> String { + format!("session:{root}") +} + +/// One open file. `mtime` is what the conflict guard compares against, so it +/// is part of the shared record rather than per-surface bookkeeping — two +/// surfaces editing one file must agree on which version they started from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Buffer { + /// Path relative to the workspace root. + pub path: String, + /// Last-modified time this buffer was read at, Unix seconds. + pub mtime: i64, + /// Monaco language id for the path. + pub language: String, +} + +/// Everything remembered about one project. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Session { + /// Open buffers, in the order they were opened. + #[serde(default)] + pub buffers: Vec, + /// Root-relative folder paths currently expanded in the tree. + #[serde(default)] + pub expanded: Vec, +} + +impl Session { + /// Record a buffer, replacing any existing one for the same path. + /// + /// Re-opening a file must not stack duplicate tabs, and it must refresh + /// the mtime — the second open is by definition the newer read. + pub fn upsert(&mut self, buffer: Buffer) { + match self.buffers.iter_mut().find(|b| b.path == buffer.path) { + Some(existing) => *existing = buffer, + None => self.buffers.push(buffer), + } + } + + pub fn close(&mut self, path: &str) -> bool { + let before = self.buffers.len(); + self.buffers.retain(|b| b.path != path); + before != self.buffers.len() + } + + pub fn expand(&mut self, path: &str) { + if !self.expanded.iter().any(|p| p == path) { + self.expanded.push(path.to_string()); + } + } + + pub fn collapse(&mut self, path: &str) { + // Collapsing a folder collapses what is under it; leaving descendants + // marked expanded would re-open them the moment the parent reopens. + self.expanded.retain(|p| p != path && !is_under(p, path)); + } + + /// Rewrite every path at or under `from` to sit under `to`. + /// + /// This is the single reason moves go through one function. A buffer left + /// pointing at the old path saves its contents back to where the file used + /// to be, recreating the folder that was just moved — the failure is + /// silent, and it is data loss. Renaming a folder invalidates paths in + /// bulk, so the remap has to cover descendants, not just exact matches. + pub fn remap(&mut self, from: &str, to: &str) -> usize { + let mut changed = 0; + for buffer in self.buffers.iter_mut() { + if let Some(next) = remapped(&buffer.path, from, to) { + buffer.path = next; + changed += 1; + } + } + for path in self.expanded.iter_mut() { + if let Some(next) = remapped(path, from, to) { + *path = next; + changed += 1; + } + } + changed + } +} + +/// `true` when `path` sits inside directory `dir` (not equal to it). +fn is_under(path: &str, dir: &str) -> bool { + path.len() > dir.len() && path.starts_with(dir) && path.as_bytes()[dir.len()] == b'/' +} + +/// The rewritten path, or `None` when `path` is unaffected by the move. +/// +/// Matching is on whole segments: moving `src/app` must not touch +/// `src/application.rs`, which a plain `starts_with` would rewrite into +/// nonsense. +fn remapped(path: &str, from: &str, to: &str) -> Option { + if path == from { + return Some(to.to_string()); + } + if is_under(path, from) { + return Some(format!("{to}{}", &path[from.len()..])); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buf(path: &str) -> Buffer { + Buffer { + path: path.to_string(), + mtime: 1, + language: "rust".to_string(), + } + } + + #[test] + fn session_key_is_per_project() { + assert_ne!(session_key("/a"), session_key("/b")); + assert!(session_key("/a/b").contains("/a/b")); + } + + #[test] + fn reopening_a_file_replaces_rather_than_duplicates() { + let mut s = Session::default(); + s.upsert(buf("a.rs")); + let mut newer = buf("a.rs"); + newer.mtime = 99; + s.upsert(newer); + assert_eq!(s.buffers.len(), 1); + assert_eq!(s.buffers[0].mtime, 99, "the newer read wins"); + } + + #[test] + fn close_reports_whether_anything_was_open() { + let mut s = Session::default(); + s.upsert(buf("a.rs")); + assert!(s.close("a.rs")); + assert!(!s.close("a.rs")); + } + + #[test] + fn collapsing_a_folder_collapses_its_descendants() { + let mut s = Session::default(); + s.expand("src"); + s.expand("src/app"); + s.expand("src/app/deep"); + s.expand("tests"); + s.collapse("src"); + assert_eq!(s.expanded, vec!["tests".to_string()]); + } + + #[test] + fn expand_is_idempotent() { + let mut s = Session::default(); + s.expand("src"); + s.expand("src"); + assert_eq!(s.expanded.len(), 1); + } + + #[test] + fn moving_a_file_remaps_its_buffer() { + let mut s = Session::default(); + s.upsert(buf("old.rs")); + assert_eq!(s.remap("old.rs", "new.rs"), 1); + assert_eq!(s.buffers[0].path, "new.rs"); + } + + #[test] + fn moving_a_folder_remaps_every_path_under_it() { + let mut s = Session::default(); + s.upsert(buf("src/app/a.rs")); + s.upsert(buf("src/app/deep/b.rs")); + s.upsert(buf("other.rs")); + s.expand("src/app"); + s.expand("src/app/deep"); + + s.remap("src/app", "src/ui"); + + let paths: Vec<&str> = s.buffers.iter().map(|b| b.path.as_str()).collect(); + assert_eq!(paths, vec!["src/ui/a.rs", "src/ui/deep/b.rs", "other.rs"]); + assert_eq!(s.expanded, vec!["src/ui", "src/ui/deep"]); + } + + #[test] + fn remap_matches_whole_segments_only() { + // `src/app` must not swallow `src/application.rs`. + let mut s = Session::default(); + s.upsert(buf("src/application.rs")); + s.upsert(buf("src/app/x.rs")); + s.remap("src/app", "src/ui"); + let paths: Vec<&str> = s.buffers.iter().map(|b| b.path.as_str()).collect(); + assert_eq!(paths, vec!["src/application.rs", "src/ui/x.rs"]); + } + + #[test] + fn remap_leaves_unrelated_paths_alone() { + let mut s = Session::default(); + s.upsert(buf("a.rs")); + assert_eq!(s.remap("b.rs", "c.rs"), 0); + assert_eq!(s.buffers[0].path, "a.rs"); + } + + #[test] + fn session_round_trips_through_json() { + let mut s = Session::default(); + s.upsert(buf("a.rs")); + s.expand("src"); + let json = serde_json::to_string(&s).unwrap(); + let back: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn missing_fields_default_so_an_old_record_still_loads() { + let back: Session = serde_json::from_str("{}").unwrap(); + assert!(back.buffers.is_empty()); + assert!(back.expanded.is_empty()); + } +} diff --git a/editor/tests/golden/schemas/editor.buffers.close.json b/editor/tests/golden/schemas/editor.buffers.close.json new file mode 100644 index 000000000..268e3c7bf --- /dev/null +++ b/editor/tests/golden/schemas/editor.buffers.close.json @@ -0,0 +1,69 @@ +{ + "description": "Close one open buffer. The file on disk is untouched.", + "function_id": "editor::buffers::close", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "path": { + "description": "Root-relative path of the buffer to close.", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "BufferCloseInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "closed": { + "description": "False when nothing was open at that path.", + "type": "boolean" + }, + "root": { + "type": "string" + } + }, + "required": [ + "buffers", + "closed", + "root" + ], + "title": "BufferCloseOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.buffers.list.json b/editor/tests/golden/schemas/editor.buffers.list.json new file mode 100644 index 000000000..63490cee7 --- /dev/null +++ b/editor/tests/golden/schemas/editor.buffers.list.json @@ -0,0 +1,55 @@ +{ + "description": "Files currently open in the workspace.", + "function_id": "editor::buffers::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EmptyInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "root": { + "type": "string" + } + }, + "required": [ + "buffers", + "root" + ], + "title": "BuffersOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.create.json b/editor/tests/golden/schemas/editor.create.json new file mode 100644 index 000000000..21cdd4957 --- /dev/null +++ b/editor/tests/golden/schemas/editor.create.json @@ -0,0 +1,77 @@ +{ + "description": "Create a file or folder in the workspace, with parents as needed. A file may be seeded with content.", + "function_id": "editor::create", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "EntryKind": { + "description": "Create a file or a folder.", + "enum": [ + "file", + "folder" + ], + "type": "string" + } + }, + "properties": { + "content": { + "default": null, + "description": "Initial contents for a file. Ignored for a folder.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "allOf": [ + { + "$ref": "#/definitions/EntryKind" + } + ], + "default": "file", + "description": "What to create. Defaults to a file." + }, + "path": { + "description": "Root-relative path to create. Missing parent folders are created.", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "CreateInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "EntryKind": { + "description": "Create a file or a folder.", + "enum": [ + "file", + "folder" + ], + "type": "string" + } + }, + "properties": { + "created": { + "description": "Always true on success; the call errors rather than reporting false.", + "type": "boolean" + }, + "kind": { + "$ref": "#/definitions/EntryKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "created", + "kind", + "path" + ], + "title": "CreateOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.delete.json b/editor/tests/golden/schemas/editor.delete.json new file mode 100644 index 000000000..a4d32ab86 --- /dev/null +++ b/editor/tests/golden/schemas/editor.delete.json @@ -0,0 +1,81 @@ +{ + "description": "Remove a path and close any buffer it held. An open buffer for a deleted file would recreate it on the next save.", + "function_id": "editor::delete", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "path": { + "description": "Root-relative path to remove.", + "type": "string" + }, + "recursive": { + "default": false, + "description": "Required to remove a non-empty folder.", + "type": "boolean" + } + }, + "required": [ + "path" + ], + "title": "DeleteInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "buffers_closed": { + "description": "Buffers closed because their file is gone. Leaving them open would let the next save recreate a file the user just deleted.", + "items": { + "type": "string" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "buffers", + "buffers_closed", + "deleted", + "path" + ], + "title": "DeleteOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.diff.json b/editor/tests/golden/schemas/editor.diff.json new file mode 100644 index 000000000..d3184175f --- /dev/null +++ b/editor/tests/golden/schemas/editor.diff.json @@ -0,0 +1,140 @@ +{ + "description": "Unified diff between two texts. Pure: nothing is read from disk. Use it to show what an edit will do before writing it, or to explain what a write did.", + "function_id": "editor::diff", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "after": { + "description": "The text as it will be.", + "type": "string" + }, + "before": { + "description": "The text as it was.", + "type": "string" + }, + "context_lines": { + "default": null, + "description": "Unchanged lines kept around each hunk (`-U` of `git diff`). Defaults to the worker's `diff_context_lines`.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "path": { + "default": null, + "description": "Path used to label the patch header. Nothing is read from disk — this is presentation only.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "after", + "before" + ], + "title": "DiffInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Hunk": { + "description": "One `@@` block: the line ranges it covers on each side, plus the counts a gutter needs without re-reading the patch body.", + "properties": { + "added": { + "description": "Lines added within this hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "new_lines": { + "description": "Number of \"after\" lines the hunk spans.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "new_start": { + "description": "First line of the hunk on the \"after\" side, 1-based.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "old_lines": { + "description": "Number of \"before\" lines the hunk spans.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "old_start": { + "description": "First line of the hunk on the \"before\" side, 1-based. `0` when the before side is empty (pure addition), matching unified-diff convention.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "removed": { + "description": "Lines removed within this hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "added", + "new_lines", + "new_start", + "old_lines", + "old_start", + "removed" + ], + "type": "object" + } + }, + "description": "A rendered patch plus the structured view of the same edits.", + "properties": { + "added": { + "description": "Total lines added across every hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "hunks": { + "description": "One entry per `@@` block, in file order.", + "items": { + "$ref": "#/definitions/Hunk" + }, + "type": "array" + }, + "identical": { + "description": "True when `before` and `after` are byte-identical.", + "type": "boolean" + }, + "patch": { + "description": "Unified diff. Empty when the two sides are identical.", + "type": "string" + }, + "removed": { + "description": "Total lines removed across every hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "truncated": { + "description": "True when either side exceeded `max_bytes` and the diff was skipped. `patch` and `hunks` are empty in that case — a caller that ignores this flag would read \"no changes\" from a file that was simply too big.", + "type": "boolean" + } + }, + "required": [ + "added", + "hunks", + "identical", + "patch", + "removed", + "truncated" + ], + "title": "DiffResult", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.find.json b/editor/tests/golden/schemas/editor.find.json new file mode 100644 index 000000000..a03b49705 --- /dev/null +++ b/editor/tests/golden/schemas/editor.find.json @@ -0,0 +1,95 @@ +{ + "description": "Fuzzy file finder over the workspace, ranked the way an editor's open-file palette ranks. Candidates come from git when the root is a repository and from the folder listing when it is not.", + "function_id": "editor::find", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "include_untracked": { + "default": true, + "description": "Include files git does not track yet (still honouring `.gitignore`). On by default — a file the agent just created is exactly the one you are looking for. Ignored outside a repository, where the workspace listing is the source of candidates.", + "type": "boolean" + }, + "limit": { + "default": null, + "description": "Rows to return. Defaults to the worker's `find_limit`.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "query": { + "description": "Fuzzy query. Matched as a subsequence against every tracked path, with basename and word-boundary hits ranked highest. Empty returns the first `limit` paths unranked.", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "FindInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "FindMatch": { + "properties": { + "path": { + "type": "string" + }, + "positions": { + "description": "Byte offsets into `path` that matched, in order — enough to highlight the match without re-running the matcher in the UI.", + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": "array" + }, + "score": { + "description": "Higher is better. Comparable only within one response.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "path", + "positions", + "score" + ], + "type": "object" + } + }, + "properties": { + "from_git": { + "description": "True when candidates came from git's listing (so `.gitignore` was honoured), false when they came from the folder walk.", + "type": "boolean" + }, + "matches": { + "items": { + "$ref": "#/definitions/FindMatch" + }, + "type": "array" + }, + "scanned": { + "description": "Paths considered. Compare against `truncated` to know whether the whole repo was ranked.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "truncated": { + "description": "True when the workspace held more paths than `max_find_candidates` and only the first of them were ranked.", + "type": "boolean" + } + }, + "required": [ + "from_git", + "matches", + "scanned", + "truncated" + ], + "title": "FindOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.commit.json b/editor/tests/golden/schemas/editor.git.commit.json new file mode 100644 index 000000000..a44b93c76 --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.commit.json @@ -0,0 +1,57 @@ +{ + "description": "Stage and commit. Returns the new SHA, or committed:false when there was nothing staged.", + "function_id": "editor::git::commit", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "default": null, + "description": "Repository to run in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + }, + "message": { + "description": "Commit message. Passed as a single `-m` argument.", + "type": "string" + }, + "stage_all": { + "default": true, + "description": "Stage every change first (`git add -A`). On by default, matching what an editor's commit command does; pass false to commit only the index.", + "type": "boolean" + } + }, + "required": [ + "message" + ], + "title": "GitCommitInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "committed": { + "description": "False when there was nothing staged to commit.", + "type": "boolean" + }, + "sha": { + "description": "Full SHA of the new commit, when one was made.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "git's own summary line, verbatim.", + "type": "string" + } + }, + "required": [ + "committed", + "summary" + ], + "title": "GitCommitOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.hunks.json b/editor/tests/golden/schemas/editor.git.hunks.json new file mode 100644 index 000000000..aff2b5170 --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.hunks.json @@ -0,0 +1,203 @@ +{ + "description": "What changed in one file: the rendered patch plus its line ranges. Compares the working tree against the index, the index against HEAD, or the working tree against HEAD — so it shows an edit made by anything, including an agent that never called this worker.", + "function_id": "editor::git::hunks", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Against": { + "description": "Which copy of the file the working tree is compared against.", + "oneOf": [ + { + "description": "Unstaged edits: working tree vs the index.", + "enum": [ + "worktree" + ], + "type": "string" + }, + { + "description": "Staged edits: index vs HEAD.", + "enum": [ + "index" + ], + "type": "string" + }, + { + "description": "Everything since the last commit: working tree vs HEAD.", + "enum": [ + "head" + ], + "type": "string" + } + ] + } + }, + "properties": { + "against": { + "allOf": [ + { + "$ref": "#/definitions/Against" + } + ], + "default": "worktree", + "description": "Which comparison to make. Defaults to `worktree`." + }, + "context_lines": { + "default": null, + "description": "Unchanged lines kept around each hunk in `patch`. Defaults to 3, which reads well. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "cwd": { + "default": null, + "description": "Directory to run git in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Repository-relative path to inspect.", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "GitHunksInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Against": { + "description": "Which copy of the file the working tree is compared against.", + "oneOf": [ + { + "description": "Unstaged edits: working tree vs the index.", + "enum": [ + "worktree" + ], + "type": "string" + }, + { + "description": "Staged edits: index vs HEAD.", + "enum": [ + "index" + ], + "type": "string" + }, + { + "description": "Everything since the last commit: working tree vs HEAD.", + "enum": [ + "head" + ], + "type": "string" + } + ] + }, + "Hunk": { + "description": "One `@@` block: the line ranges it covers on each side, plus the counts a gutter needs without re-reading the patch body.", + "properties": { + "added": { + "description": "Lines added within this hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "new_lines": { + "description": "Number of \"after\" lines the hunk spans.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "new_start": { + "description": "First line of the hunk on the \"after\" side, 1-based.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "old_lines": { + "description": "Number of \"before\" lines the hunk spans.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "old_start": { + "description": "First line of the hunk on the \"before\" side, 1-based. `0` when the before side is empty (pure addition), matching unified-diff convention.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "removed": { + "description": "Lines removed within this hunk.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "added", + "new_lines", + "new_start", + "old_lines", + "old_start", + "removed" + ], + "type": "object" + } + }, + "properties": { + "added": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "against": { + "allOf": [ + { + "$ref": "#/definitions/Against" + } + ], + "description": "Echo of the comparison performed, so a cached response is self-describing." + }, + "hunks": { + "description": "Changed ranges, in file order. Empty when the file matches.", + "items": { + "$ref": "#/definitions/Hunk" + }, + "type": "array" + }, + "patch": { + "description": "The rendered unified patch, for showing a person what changed. Empty when there is no difference, and capped by `max_diff_bytes` — a caller that only wants the ranges can ignore it.", + "type": "string" + }, + "path": { + "type": "string" + }, + "removed": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "untracked": { + "description": "True when git reports the path as untracked, in which case there is nothing to compare against and `hunks` is empty.", + "type": "boolean" + } + }, + "required": [ + "added", + "against", + "hunks", + "patch", + "path", + "removed", + "untracked" + ], + "title": "GitHunksOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.show.json b/editor/tests/golden/schemas/editor.git.show.json new file mode 100644 index 000000000..ea001536a --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.show.json @@ -0,0 +1,61 @@ +{ + "description": "Read a file's contents at a revision (HEAD by default). Pair it with the working copy to render a real side-by-side or unified diff, rather than parsing a patch.", + "function_id": "editor::git::show", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "default": null, + "description": "Repository to run in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Root-relative path.", + "type": "string" + }, + "rev": { + "default": null, + "description": "Revision to read the file at. Defaults to `HEAD`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "GitShowInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "content": { + "description": "The file's contents at that revision. Empty when the path did not exist there — which is what `exists: false` distinguishes from an empty file.", + "type": "string" + }, + "exists": { + "description": "False when the path is absent at that revision (a file being added).", + "type": "boolean" + }, + "path": { + "type": "string" + }, + "rev": { + "type": "string" + } + }, + "required": [ + "content", + "exists", + "path", + "rev" + ], + "title": "GitShowOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.stash.json b/editor/tests/golden/schemas/editor.git.stash.json new file mode 100644 index 000000000..e9c4ed260 --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.stash.json @@ -0,0 +1,69 @@ +{ + "description": "Stash the working tree, or pop the most recent stash.", + "function_id": "editor::git::stash", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "StashAction": { + "enum": [ + "push", + "pop" + ], + "type": "string" + } + }, + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/definitions/StashAction" + } + ], + "description": "Stash the working tree, or restore the most recent stash." + }, + "cwd": { + "default": null, + "description": "Repository to run in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action" + ], + "title": "GitStashInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "StashAction": { + "enum": [ + "push", + "pop" + ], + "type": "string" + } + }, + "properties": { + "action": { + "$ref": "#/definitions/StashAction" + }, + "ok": { + "type": "boolean" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "action", + "ok", + "summary" + ], + "title": "GitStashOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.status.json b/editor/tests/golden/schemas/editor.git.status.json new file mode 100644 index 000000000..0249789e9 --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.status.json @@ -0,0 +1,107 @@ +{ + "description": "Working-tree status as typed rows: branch, upstream, ahead/behind, and one entry per changed path. Fails when the root is not a repository — that is an absent overlay, not a broken workspace.", + "function_id": "editor::git::status", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "default": null, + "description": "Directory to run git in. Defaults to the workspace root. Confined by shell's jail exactly like any other `shell::exec` call.", + "type": [ + "string", + "null" + ] + } + }, + "title": "GitStatusInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "StatusEntry": { + "description": "A single changed path as git sees it.", + "properties": { + "index": { + "description": "Status of the staged copy: `modified`, `added`, `deleted`, `renamed`, `copied`, `untracked`, `ignored`, `conflicted`, or `unchanged`.", + "type": "string" + }, + "path": { + "description": "Path relative to the repository root.", + "type": "string" + }, + "renamed_from": { + "description": "Original path for a rename or copy.", + "type": [ + "string", + "null" + ] + }, + "staged": { + "description": "True when the staged copy differs from HEAD.", + "type": "boolean" + }, + "worktree": { + "description": "Status of the working-tree copy, same vocabulary as `index`.", + "type": "string" + } + }, + "required": [ + "index", + "path", + "staged", + "worktree" + ], + "type": "object" + } + }, + "description": "Branch header plus every changed path.", + "properties": { + "ahead": { + "description": "Commits on this branch the upstream does not have.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "behind": { + "description": "Commits on the upstream this branch does not have.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "branch": { + "description": "Current branch, or `None` on a detached HEAD.", + "type": [ + "string", + "null" + ] + }, + "clean": { + "description": "True when nothing is modified, staged, or untracked.", + "type": "boolean" + }, + "entries": { + "description": "One row per changed path, in git's order.", + "items": { + "$ref": "#/definitions/StatusEntry" + }, + "type": "array" + }, + "upstream": { + "description": "Configured upstream, e.g. `origin/main`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "ahead", + "behind", + "clean", + "entries" + ], + "title": "StatusReport", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.sync.json b/editor/tests/golden/schemas/editor.git.sync.json new file mode 100644 index 000000000..41e634fcf --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.sync.json @@ -0,0 +1,107 @@ +{ + "description": "Fetch, fast-forward pull, or push. Pull is --ff-only on purpose: a merge under open buffers is how an editor ends up showing a conflicted tree it never asked for.", + "function_id": "editor::git::sync", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SyncAction": { + "description": "Which remote operation to run.", + "oneOf": [ + { + "enum": [ + "fetch", + "push" + ], + "type": "string" + }, + { + "description": "Fast-forward only. A pull that would merge fails instead, so this can never produce a conflicted tree under open buffers.", + "enum": [ + "pull" + ], + "type": "string" + } + ] + } + }, + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/definitions/SyncAction" + } + ], + "description": "Which remote operation to run." + }, + "cwd": { + "default": null, + "description": "Repository to run in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action" + ], + "title": "GitSyncInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SyncAction": { + "description": "Which remote operation to run.", + "oneOf": [ + { + "enum": [ + "fetch", + "push" + ], + "type": "string" + }, + { + "description": "Fast-forward only. A pull that would merge fails instead, so this can never produce a conflicted tree under open buffers.", + "enum": [ + "pull" + ], + "type": "string" + } + ] + } + }, + "properties": { + "action": { + "$ref": "#/definitions/SyncAction" + }, + "ahead": { + "description": "Ahead/behind after the operation, so a caller does not need a second round trip to find out whether it changed anything.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "behind": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "summary": { + "description": "git's output, trimmed. Both streams: git reports progress on stderr.", + "type": "string" + } + }, + "required": [ + "action", + "ahead", + "behind", + "ok", + "summary" + ], + "title": "GitSyncOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.git.undo-commit.json b/editor/tests/golden/schemas/editor.git.undo-commit.json new file mode 100644 index 000000000..6a8d2010b --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.undo-commit.json @@ -0,0 +1,38 @@ +{ + "description": "Undo the last commit, keeping its changes staged (reset --soft HEAD~1). Returns the SHA and message that were undone.", + "function_id": "editor::git::undo-commit", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "default": null, + "description": "Repository to run in. Defaults to the workspace root.", + "type": [ + "string", + "null" + ] + } + }, + "title": "GitUndoCommitInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Its message, so a caller can put it straight back in a commit box.", + "type": "string" + }, + "undone_sha": { + "description": "SHA that was undone.", + "type": "string" + } + }, + "required": [ + "message", + "undone_sha" + ], + "title": "GitUndoCommitOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.move.json b/editor/tests/golden/schemas/editor.move.json new file mode 100644 index 000000000..24a4517c6 --- /dev/null +++ b/editor/tests/golden/schemas/editor.move.json @@ -0,0 +1,84 @@ +{ + "description": "Move or rename a path and rewrite every open buffer and expanded folder at or under it. Moving a folder with `shell::fs::mv` alone leaves buffers pointing at the old location, which silently recreates it on the next save.", + "function_id": "editor::move", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "from": { + "description": "Existing root-relative path.", + "type": "string" + }, + "to": { + "description": "Destination root-relative path.", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "MoveInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "from": { + "type": "string" + }, + "remapped": { + "description": "Open buffers and expanded folders rewritten to the new location. A folder move rewrites everything beneath it, which is the whole reason moves go through this function instead of `shell::fs::mv` directly.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "root": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "buffers", + "from", + "remapped", + "root", + "to" + ], + "title": "MoveOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.open.json b/editor/tests/golden/schemas/editor.open.json new file mode 100644 index 000000000..f6b1e9668 --- /dev/null +++ b/editor/tests/golden/schemas/editor.open.json @@ -0,0 +1,58 @@ +{ + "description": "Read a text file and record it as an open buffer, with the metadata needed to write it back safely: its language id and the mtime to hand to editor::save.", + "function_id": "editor::open", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "path": { + "description": "Jail-relative when `shell`'s `fs.host_roots` are set, else absolute — the same path vocabulary as `shell::fs::read`.", + "type": "string" + } + }, + "required": [ + "path" + ], + "title": "OpenInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "content": { + "description": "File contents as text. Binary files are refused rather than mangled.", + "type": "string" + }, + "language": { + "description": "Monaco language id for the path, e.g. `rust`, `typescript`, `plaintext`.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time, Unix seconds. Pass it back as `expected_mtime` on `editor::save` to get the conflict guard.", + "format": "int64", + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "truncated": { + "description": "True when the file exceeded `max_file_bytes` and `content` holds only its beginning. Saving a truncated buffer back would delete the rest of the file, so `editor::save` refuses one.", + "type": "boolean" + } + }, + "required": [ + "content", + "language", + "mtime", + "path", + "size", + "truncated" + ], + "title": "OpenOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.save.json b/editor/tests/golden/schemas/editor.save.json new file mode 100644 index 000000000..433fcc13b --- /dev/null +++ b/editor/tests/golden/schemas/editor.save.json @@ -0,0 +1,94 @@ +{ + "description": "Write a file, refusing the write when it changed underneath since the editor::open it started from. On refusal the divergence comes back as a patch.", + "function_id": "editor::save", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "content": { + "description": "Full new contents. This is a whole-file write, not a patch.", + "type": "string" + }, + "expected_mtime": { + "default": null, + "description": "The `mtime` from the `editor::open` this edit started from. When it no longer matches the file on disk, the write is refused and the divergence comes back as a patch. Omit only when deliberately overwriting whatever is there.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "path" + ], + "title": "SaveInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "added": { + "description": "Lines this save added relative to what was on disk before it.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "conflict": { + "description": "True when the write was refused because the file changed underneath.", + "type": "boolean" + }, + "conflict_patch": { + "description": "On conflict: a unified diff from the current disk contents to the contents you tried to write, so the divergence is reviewable without a second round trip.", + "type": [ + "string", + "null" + ] + }, + "created": { + "description": "True when the file did not exist and was created.", + "type": "boolean" + }, + "disk_mtime": { + "description": "What was on disk when a conflict was detected.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "mtime": { + "description": "Last-modified time after the write. Feed it into the next save.", + "format": "int64", + "type": "integer" + }, + "path": { + "type": "string" + }, + "removed": { + "description": "Lines this save removed.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "saved": { + "description": "True when the file was written.", + "type": "boolean" + } + }, + "required": [ + "added", + "conflict", + "created", + "mtime", + "path", + "removed", + "saved" + ], + "title": "SaveOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.search.json b/editor/tests/golden/schemas/editor.search.json new file mode 100644 index 000000000..9faa68b49 --- /dev/null +++ b/editor/tests/golden/schemas/editor.search.json @@ -0,0 +1,109 @@ +{ + "description": "Search file contents across the workspace, grouped by file — the shell worker's recursive grep, shaped into what a results panel renders.", + "function_id": "editor::search", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "ignore_case": { + "default": false, + "description": "Match case-insensitively.", + "type": "boolean" + }, + "include_glob": { + "default": [], + "description": "Glob filters restricting which files are searched, e.g. `[\"**/*.rs\"]`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "max_matches": { + "default": null, + "description": "Stop after this many matching lines. Defaults to the worker's `search_max_matches`.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "pattern": { + "description": "Rust regex matched against each line.", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "title": "SearchInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "SearchFile": { + "properties": { + "hits": { + "items": { + "$ref": "#/definitions/SearchHit" + }, + "type": "array" + }, + "path": { + "type": "string" + } + }, + "required": [ + "hits", + "path" + ], + "type": "object" + }, + "SearchHit": { + "properties": { + "line": { + "description": "1-based line number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "text": { + "description": "The matching line, as shell returned it.", + "type": "string" + } + }, + "required": [ + "line", + "text" + ], + "type": "object" + } + }, + "properties": { + "files": { + "description": "Matches grouped by file, in first-match order — the shape a result panel renders, rather than a flat list every caller has to group.", + "items": { + "$ref": "#/definitions/SearchFile" + }, + "type": "array" + }, + "total": { + "description": "Total matching lines across every file.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "truncated": { + "description": "True when the search stopped at `max_matches`.", + "type": "boolean" + } + }, + "required": [ + "files", + "total", + "truncated" + ], + "title": "SearchOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.tree.json b/editor/tests/golden/schemas/editor.tree.json new file mode 100644 index 000000000..75d4ef9fe --- /dev/null +++ b/editor/tests/golden/schemas/editor.tree.json @@ -0,0 +1,75 @@ +{ + "description": "List a folder in the workspace, with the expansion state the workspace remembers. The walk, the noise-folder excludes and the jail are the shell worker's.", + "function_id": "editor::tree", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "collapse": { + "default": [], + "description": "Root-relative folders to collapse. Collapsing takes its descendants with it.", + "items": { + "type": "string" + }, + "type": "array" + }, + "expand": { + "default": [], + "description": "Root-relative folders to mark expanded before listing. Expansion is part of the shared workspace, so it survives a reload and both surfaces agree on it.", + "items": { + "type": "string" + }, + "type": "array" + }, + "max_depth": { + "default": null, + "description": "Levels to descend. Defaults to 4 — deep enough to navigate, shallow enough that one call does not walk a whole monorepo.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "path": { + "default": null, + "description": "Folder to list, relative to the workspace root. Defaults to the root.", + "type": [ + "string", + "null" + ] + } + }, + "title": "TreeInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expanded": { + "description": "Folders the workspace has expanded, root-relative.", + "items": { + "type": "string" + }, + "type": "array" + }, + "path": { + "description": "Canonical absolute path of the listed folder, as shell resolved it.", + "type": "string" + }, + "root": { + "type": "string" + }, + "tree": { + "description": "The listing exactly as the shell worker returned it (nested `{name, kind, size, mtime, children}` nodes). Passed through rather than re-modelled so this worker does not pin shell's response shape." + } + }, + "required": [ + "expanded", + "path", + "root", + "tree" + ], + "title": "TreeOutput", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.workspace.get.json b/editor/tests/golden/schemas/editor.workspace.get.json new file mode 100644 index 000000000..bdcef463d --- /dev/null +++ b/editor/tests/golden/schemas/editor.workspace.get.json @@ -0,0 +1,65 @@ +{ + "description": "The active workspace: its root, the files open against it, and which folders are expanded. Shared by every surface, so this is what the agent and the console both see.", + "function_id": "editor::workspace::get", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EmptyInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "description": "Files currently open against this root, shared by every surface.", + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "expanded": { + "description": "Folders expanded in the tree, root-relative.", + "items": { + "type": "string" + }, + "type": "array" + }, + "root": { + "description": "The active root every other path in this response is relative to.", + "type": "string" + } + }, + "required": [ + "buffers", + "expanded", + "root" + ], + "title": "WorkspaceView", + "type": "object" + } +} diff --git a/editor/tests/golden/schemas/editor.workspace.open.json b/editor/tests/golden/schemas/editor.workspace.open.json new file mode 100644 index 000000000..b4dc6d901 --- /dev/null +++ b/editor/tests/golden/schemas/editor.workspace.open.json @@ -0,0 +1,74 @@ +{ + "description": "Set the directory the editor works in. Any folder will do — a git repository is an overlay, not a requirement. Returns the buffers and expanded folders remembered for it.", + "function_id": "editor::workspace::open", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "root": { + "description": "Directory to work in. Jail-relative when shell's `fs.host_roots` are set, else absolute. A plain folder is enough — a git repository is an overlay, never a requirement.", + "type": "string" + } + }, + "required": [ + "root" + ], + "title": "WorkspaceOpenInput", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Buffer": { + "description": "One open file. `mtime` is what the conflict guard compares against, so it is part of the shared record rather than per-surface bookkeeping — two surfaces editing one file must agree on which version they started from.", + "properties": { + "language": { + "description": "Monaco language id for the path.", + "type": "string" + }, + "mtime": { + "description": "Last-modified time this buffer was read at, Unix seconds.", + "format": "int64", + "type": "integer" + }, + "path": { + "description": "Path relative to the workspace root.", + "type": "string" + } + }, + "required": [ + "language", + "mtime", + "path" + ], + "type": "object" + } + }, + "properties": { + "buffers": { + "description": "Files currently open against this root, shared by every surface.", + "items": { + "$ref": "#/definitions/Buffer" + }, + "type": "array" + }, + "expanded": { + "description": "Folders expanded in the tree, root-relative.", + "items": { + "type": "string" + }, + "type": "array" + }, + "root": { + "description": "The active root every other path in this response is relative to.", + "type": "string" + } + }, + "required": [ + "buffers", + "expanded", + "root" + ], + "title": "WorkspaceView", + "type": "object" + } +} diff --git a/editor/tests/integration.rs b/editor/tests/integration.rs new file mode 100644 index 000000000..3af8694b5 --- /dev/null +++ b/editor/tests/integration.rs @@ -0,0 +1,125 @@ +//! End-to-end: spawn the `iii` engine and the worker, drive both through the +//! SDK as a client. Self-skips when `iii` is not on PATH, and — importantly — +//! when an engine is ALREADY listening. +//! +//! That second guard is not paranoia. A second engine cannot bind the port, so +//! it exits quietly, but the worker we spawn next connects to whatever is +//! there: the developer's live rig. It then re-registers `editor/page.js` +//! (same path ⇒ last writer wins, console-wide) and, when `Drop` kills it, +//! that Message-path trigger is GC'd — taking the real worker's console page +//! down with it. The test passes and the rig quietly loses its editor tab. +//! +//! Only `editor::diff` is exercised here, on purpose: it is the one function +//! that needs nothing but the worker itself. Everything else delegates to +//! `shell`, so testing it in this harness would be testing whether `shell` is +//! installed and jailed to the temp dir, which is `shell`'s own e2e job. The +//! delegation shape is covered by unit tests over the parsers instead. + +use std::net::TcpStream; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{register_worker, InitOptions}; +use serde_json::json; +use tokio::time::{sleep, timeout}; + +const ENGINE_WS: &str = "ws://127.0.0.1:49134"; + +struct Harness { + iii: Child, + worker: Child, +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = self.worker.kill(); + let _ = self.worker.wait(); + let _ = self.iii.kill(); + let _ = self.iii.wait(); + } +} + +/// True when something already holds the engine port. +fn engine_already_running() -> bool { + TcpStream::connect_timeout( + &"127.0.0.1:49134".parse().expect("valid socket address"), + Duration::from_millis(250), + ) + .is_ok() +} + +async fn boot() -> Option { + let iii_bin = which::which("iii").ok()?; + + if engine_already_running() { + eprintln!( + "skipping: an engine is already listening on 127.0.0.1:49134. \ + This test spawns its own worker, which would re-register the \ + editor console assets on that engine and remove them again on \ + teardown." + ); + return None; + } + + let iii = Command::new(&iii_bin) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + sleep(Duration::from_millis(800)).await; + + let worker = Command::new(env!("CARGO_BIN_EXE_editor")) + .args(["--url", ENGINE_WS]) + .args([ + "--config", + concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + sleep(Duration::from_millis(1500)).await; + + Some(Harness { iii, worker }) +} + +#[tokio::test] +async fn diff_round_trips_over_the_bus() { + let Some(_h) = boot().await else { + eprintln!("skipping: `iii` binary not on PATH"); + return; + }; + + let client = register_worker(ENGINE_WS, InitOptions::default()); + sleep(Duration::from_millis(500)).await; + + let result = timeout( + Duration::from_secs(10), + client.trigger(TriggerRequest { + function_id: "editor::diff".into(), + payload: json!({ + "before": "a\nb\nc\n", + "after": "a\nB\nc\n", + "path": "sample.txt", + }), + action: None, + timeout_ms: Some(5_000), + }), + ) + .await + .expect("trigger timed out") + .expect("trigger failed"); + + assert_eq!(result["identical"], false); + assert_eq!(result["added"], 1); + assert_eq!(result["removed"], 1); + assert!(result["patch"] + .as_str() + .expect("patch is a string") + .contains("+B")); + + client.shutdown_async().await; +} diff --git a/editor/tests/manifest.rs b/editor/tests/manifest.rs new file mode 100644 index 000000000..5853370db --- /dev/null +++ b/editor/tests/manifest.rs @@ -0,0 +1,44 @@ +use std::process::Command; + +use serde_json::Value; + +#[test] +fn manifest_subcommand_emits_valid_json() { + let bin = env!("CARGO_BIN_EXE_editor"); + let output = Command::new(bin) + .arg("--manifest") + .output() + .expect("spawn editor --manifest"); + + assert!( + output.status.success(), + "binary exited with {:?}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8(output.stdout).expect("manifest stdout is utf-8"); + let manifest: Value = serde_json::from_str(&stdout).expect("manifest stdout is valid JSON"); + + assert_eq!(manifest["name"], env!("CARGO_PKG_NAME")); + assert_eq!(manifest["version"], env!("CARGO_PKG_VERSION")); + assert!(!manifest["description"].as_str().unwrap().is_empty()); + assert!(manifest["default_config"].is_object()); + assert!(!manifest["supported_targets"] + .as_array() + .expect("supported_targets must be an array") + .is_empty()); +} + +/// `--manifest` must not touch the engine: the registry publish pipeline runs +/// it on a host with no iii running, and a connection attempt there would hang +/// the publish rather than fail it. +#[test] +fn manifest_subcommand_needs_no_engine() { + let bin = env!("CARGO_BIN_EXE_editor"); + let output = Command::new(bin) + .args(["--manifest", "--url", "ws://127.0.0.1:1"]) + .output() + .expect("spawn editor --manifest"); + assert!(output.status.success()); +} diff --git a/editor/tests/schemas.rs b/editor/tests/schemas.rs new file mode 100644 index 000000000..9e2008461 --- /dev/null +++ b/editor/tests/schemas.rs @@ -0,0 +1,111 @@ +//! Wire-schema snapshots for the six `editor::*` functions. +//! +//! `editor::surface::catalog()` is the single source of truth for each +//! function's id, registration description, and schemars-derived +//! request/response schemas (generated with the same +//! `SchemaSettings::draft07()` construction iii-sdk uses at registration, from +//! the same input/output structs). Each entry is serialized to pretty JSON and +//! compared against `tests/golden/schemas/.json` (`::` maps to `.`). +//! +//! These snapshots ARE the product surface consumed by callers and agents — +//! any schema or description change must land as an explicit golden diff. +//! Regenerate with `UPDATE_GOLDENS=1 cargo test`. + +mod support; + +use editor::functions::function_ids; +use editor::surface::{catalog, FunctionSpec}; + +fn golden_file_name(function_id: &str) -> String { + format!("schemas/{}.json", function_id.replace("::", ".")) +} + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("spec serializes"); + pretty.push('\n'); + pretty +} + +/// The catalog must cover exactly the registered functions, in registration +/// order — `function_ids()` is what `register_all` walks. +#[test] +fn catalog_matches_registration_order() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!(ids, function_ids()); +} + +/// Every catalog entry matches its committed golden. Mismatches are collected +/// across ALL functions before failing so one run shows the full drift. +#[test] +fn wire_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = golden_file_name(spec.function_id); + let actual = spec_to_pretty_json(&spec); + if let Err(msg) = support::check_golden(&rel, &actual) { + failures.push(msg); + } + } + assert!( + failures.is_empty(), + "{} wire-schema golden(s) drifted:\n\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// No function may ship the permissive `AnyValue` schema — the deploy-time +/// "unknown" request/response schema this convention exists to prevent. +#[test] +fn every_function_has_typed_request_and_response_schemas() { + for spec in catalog() { + support::assert_typed_schema( + &format!("{} request_schema", spec.function_id), + &spec.request_schema, + ); + support::assert_typed_schema( + &format!("{} response_schema", spec.function_id), + &spec.response_schema, + ); + } +} + +/// Field doc comments are the only documentation an agent sees at call time. +/// +/// Parameterless requests are exempt: `editor::workspace::get` and +/// `editor::buffers::list` take `{}`, and there is no field there to document. +/// The registration description carries their meaning instead, which the +/// golden snapshot pins. +#[test] +fn schemas_with_fields_carry_field_descriptions() { + for spec in catalog() { + let rendered = serde_json::to_string(&spec.request_schema).expect("schema serializes"); + if !rendered.contains("properties") { + continue; + } + assert!( + rendered.contains("description"), + "{}: request schema lost its field descriptions", + spec.function_id + ); + } +} + +/// Every function must carry a registration description — for the empty-input +/// ones above it is the only documentation an agent gets. +#[test] +fn every_function_has_a_description() { + for spec in catalog() { + assert!( + spec.description.len() > 20, + "{}: registration description is missing or too short to be useful", + spec.function_id + ); + } +} diff --git a/editor/tests/support/mod.rs b/editor/tests/support/mod.rs new file mode 100644 index 000000000..440e3bf0e --- /dev/null +++ b/editor/tests/support/mod.rs @@ -0,0 +1,118 @@ +//! Hand-rolled golden-file harness (deliberately no `insta`/snapshot +//! dependency). Goldens live under `tests/golden/` and are committed; +//! any wire-surface change must show up as an explicit, reviewed diff. +//! +//! Workflow: +//! - `cargo test` compares actual output against the committed goldens. +//! - `UPDATE_GOLDENS=1 cargo test` regenerates the files; review the git +//! diff, then commit the new goldens alongside the change that caused +//! them. + +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +/// Root of the committed golden files. +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn update_mode() -> bool { + std::env::var("UPDATE_GOLDENS") + .map(|v| v == "1") + .unwrap_or(false) +} + +/// Compare `actual` against the golden file at `tests/golden/`. +/// Returns `Err(readable diff hint)` on mismatch or missing golden; +/// with `UPDATE_GOLDENS=1` the file is (re)written and the check passes. +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if update_mode() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, actual).map_err(|e| format!("write {}: {e}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden file {} unreadable ({e}).\n\ + Run `UPDATE_GOLDENS=1 cargo test` to (re)generate, then review \ + and commit the diff.", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(diff_hint(rel, &expected, actual)) +} + +/// Readable first-divergence diff hint: line number, expected vs actual +/// around the mismatch, and the regeneration instructions. +fn diff_hint(rel: &str, expected: &str, actual: &str) -> String { + let exp_lines: Vec<&str> = expected.lines().collect(); + let act_lines: Vec<&str> = actual.lines().collect(); + let first_diff = exp_lines + .iter() + .zip(act_lines.iter()) + .position(|(e, a)| e != a) + .unwrap_or_else(|| exp_lines.len().min(act_lines.len())); + + const CONTEXT: usize = 3; + let lo = first_diff.saturating_sub(CONTEXT); + let hi = (first_diff + CONTEXT + 1).max(first_diff + 1); + + let mut out = format!( + "golden mismatch: tests/golden/{rel}\n\ + first divergence at line {} (expected {} lines, actual {} lines)\n", + first_diff + 1, + exp_lines.len(), + act_lines.len() + ); + out.push_str("--- expected (golden) ---\n"); + for (i, line) in exp_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str("--- actual ---\n"); + for (i, line) in act_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str( + "If this change is intentional, run `UPDATE_GOLDENS=1 cargo test`, \ + review the git diff, and commit the updated goldens.\n", + ); + out +} + +/// Assert a schemars-derived request/response schema is a *real* schema and +/// not the permissive `AnyValue` schema a `Value` handler emits (the "unknown" +/// schema this whole convention exists to prevent). A real schema carries at +/// least one schema-defining keyword. +pub fn assert_typed_schema(label: &str, schema: &schemars::schema::RootSchema) { + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("{label}: schema is not a JSON object")); + const DEFINING: [&str; 8] = [ + "type", + "properties", + "$ref", + "allOf", + "anyOf", + "oneOf", + "enum", + "items", + ]; + let has_defining = DEFINING.iter().any(|k| obj.contains_key(*k)); + assert!( + has_defining, + "{label}: schema is the permissive AnyValue/empty schema (no type/properties/$ref/…). \ + The handler is registered with `Value` — give it a typed struct deriving JsonSchema. \ + Got: {value}" + ); +} diff --git a/editor/ui/build.mjs b/editor/ui/build.mjs new file mode 100644 index 000000000..ba3ee5242 --- /dev/null +++ b/editor/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) + * + * The five shared specifiers stay EXTERNAL — they resolve at runtime through + * the console's import map. A bundled second React copy surfaces as a cryptic + * "Invalid hook call" with nothing pointing at the cause, and a bundled editor + * would ship megabytes to duplicate the Monaco the console already runs. + * `--watch` pairs with the worker's III_EDITOR_UI_WATCH poller. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/editor/ui/package.json b/editor/ui/package.json new file mode 100644 index 000000000..2ffa3d0cc --- /dev/null +++ b/editor/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@iii-workers/editor-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/editor/ui/page.tsx b/editor/ui/page.tsx new file mode 100644 index 000000000..39f23d44f --- /dev/null +++ b/editor/ui/page.tsx @@ -0,0 +1,29 @@ +/** + * Entry for the editor worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/page.js and served over the + * `console:script` trigger (see src/ui.rs). The stylesheet is its own asset: + * styles.css ships over `console:style` as editor/styles.css. + * + * `setup(host)` registers two contributions: + * - src/function-trigger-message/ — how every editor::* call renders in chat + * and traces (diffs as diffs, saves as file cards). + * - src/page/ — the `#/ext/editor` page: changed files, tabs, the shared + * Monaco editor, unsaved-diff view, and the live feed of edits landing. + * + * Registrations go through `host` so the loader disposes them on hot reload + * and on worker disconnect. + */ + +import type { Host } from '@iii-dev/console-ui' +import { createEditorTriggerRenderer } from './src/function-trigger-message' +import { EditorPage } from './src/page' + +export default function setup(host: Host) { + host.functionTriggers.register(createEditorTriggerRenderer(host)) + + host.pages.register({ + id: 'editor', + title: 'editor', + render: () => , + }) +} diff --git a/editor/ui/src/function-trigger-message/index.tsx b/editor/ui/src/function-trigger-message/index.tsx new file mode 100644 index 000000000..9fba51479 --- /dev/null +++ b/editor/ui/src/function-trigger-message/index.tsx @@ -0,0 +1,311 @@ +/** + * How `editor::*` calls render in chat and traces. + * + * A patch shown as JSON is unreadable — the escaped newlines are longer than + * the diff itself. These renderers exist so the agent's edits read as edits: + * a diff renders as a diff, a save renders as a file card with its line + * counts, a find renders as the list you would have scanned anyway. + * + * Every renderer matches narrowly on its own function id and returns `null` + * on anything unexpected, so an error or a shape we did not anticipate falls + * through to the console's default card rather than rendering wrong. + */ + +import { + Badge, + CodeHighlight, + type FunctionTriggerMessage, + type FunctionTriggerRenderer, + type Host, +} from '@iii-dev/console-ui' + +const HANDLED = new Set([ + 'editor::workspace::open', + 'editor::workspace::get', + 'editor::buffers::list', + 'editor::buffers::close', + 'editor::move', + 'editor::diff', + 'editor::save', + 'editor::open', + 'editor::find', + 'editor::git::status', + 'editor::git::hunks', +]) + +/** Patches are bounded before they reach the DOM; a feed row is not a viewer. */ +const MAX_PATCH_LINES = 400 + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function str(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function num(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +function clampPatch(patch: string): { text: string; clipped: boolean } { + const lines = patch.split('\n') + if (lines.length <= MAX_PATCH_LINES) return { text: patch, clipped: false } + return { text: lines.slice(0, MAX_PATCH_LINES).join('\n'), clipped: true } +} + +function Stat({ added, removed }: { added: number | null; removed: number | null }) { + if (added === null && removed === null) return null + return ( + + {added !== null && added > 0 && +{added}} + {removed !== null && removed > 0 && −{removed}} + {added === 0 && removed === 0 && no change} + + ) +} + +function Patch({ patch }: { patch: string }) { + const { text, clipped } = clampPatch(patch) + return ( +
+ + {clipped && ( +
shown to the first {MAX_PATCH_LINES} lines — open the file to see the rest
+ )} +
+ ) +} + +/** Workspace-shaped responses: the root and the tabs open against it. */ +function renderWorkspace(output: Record) { + const buffers = Array.isArray(output.buffers) ? output.buffers : null + if (buffers === null) return null + const root = str(output.root) + return ( +
+
+ {root !== null && {root}} + {buffers.length === 0 ? 'no files open' : `${buffers.length} open`} +
+ {buffers.length > 0 && ( +
    + {buffers.slice(0, 12).map((raw, index) => { + const buffer = record(raw) + const path = buffer ? str(buffer.path) : null + return ( +
  • + {path ?? '—'} +
  • + ) + })} +
+ )} +
+ ) +} + +function renderMove(output: Record) { + const from = str(output.from) + const to = str(output.to) + if (from === null || to === null) return null + const remapped = num(output.remapped) ?? 0 + return ( +
+
+ {from} + + {to} + {remapped > 0 && {`${remapped} remapped`}} +
+
+ ) +} + +function renderDiff(output: Record) { + if (output.identical === true) { + return ( +
+ identical — no diff +
+ ) + } + if (output.truncated === true) { + return ( +
+ too large to diff +
+ ) + } + const patch = str(output.patch) + if (patch === null) return null + return ( +
+ + +
+ ) +} + +function renderSave(output: Record) { + const path = str(output.path) + if (path === null) return null + if (output.conflict === true) { + const patch = str(output.conflict_patch) + return ( +
+
+ conflict + {path} +
+
Not written — the file changed since it was opened.
+ {patch !== null && } +
+ ) + } + return ( +
+
+ + {output.created === true ? 'created' : 'saved'} + + {path} + +
+
+ ) +} + +function renderOpen(output: Record) { + const path = str(output.path) + if (path === null) return null + const size = num(output.size) + return ( +
+
+ {path} + {str(output.language) !== null && {str(output.language)}} + {size !== null && {size} B} + {output.truncated === true && truncated} +
+
+ ) +} + +function renderFind(output: Record) { + const matches = Array.isArray(output.matches) ? output.matches : null + if (matches === null) return null + if (matches.length === 0) { + return ( +
+ no match +
+ ) + } + return ( +
+
    + {matches.slice(0, 12).map((raw, index) => { + const match = record(raw) + const path = match ? str(match.path) : null + return ( +
  • + {path ?? '—'} +
  • + ) + })} +
+ {matches.length > 12 &&
+{matches.length - 12} more
} +
+ ) +} + +function renderStatus(output: Record) { + const entries = Array.isArray(output.entries) ? output.entries : null + if (entries === null) return null + const branch = str(output.branch) + return ( +
+
+ {branch !== null && {branch}} + {entries.length === 0 ? 'clean' : `${entries.length} changed`} +
+
    + {entries.slice(0, 12).map((raw, index) => { + const entry = record(raw) + const path = entry ? str(entry.path) : null + const state = entry ? str(entry.worktree) : null + return ( +
  • + {state ?? '?'} {path ?? '—'} +
  • + ) + })} +
+
+ ) +} + +function renderHunks(output: Record) { + const path = str(output.path) + if (path === null) return null + if (output.untracked === true) { + return ( +
+
+ untracked + {path} +
+
+ ) + } + const hunks = Array.isArray(output.hunks) ? output.hunks : [] + return ( +
+
+ {path} + + {hunks.length === 0 ? 'unchanged' : `${hunks.length} hunk${hunks.length === 1 ? '' : 's'}`} + + +
+
+ ) +} + +export function createEditorTriggerRenderer(_host: Host): FunctionTriggerRenderer { + return { + id: 'editor', + isMatch: (functionId: string) => HANDLED.has(functionId), + tryRender(message: FunctionTriggerMessage) { + const output = record(message.output) + if (output === null) return null + // An error payload keeps the console's default error card. + if (output.error !== undefined) return null + + switch (message.functionId) { + case 'editor::workspace::open': + case 'editor::workspace::get': + case 'editor::buffers::list': + case 'editor::buffers::close': + return renderWorkspace(output) + case 'editor::move': + return renderMove(output) + case 'editor::diff': + return renderDiff(output) + case 'editor::save': + return renderSave(output) + case 'editor::open': + return renderOpen(output) + case 'editor::find': + return renderFind(output) + case 'editor::git::status': + return renderStatus(output) + case 'editor::git::hunks': + return renderHunks(output) + default: + return null + } + }, + } +} diff --git a/editor/ui/src/lib/api.ts b/editor/ui/src/lib/api.ts new file mode 100644 index 000000000..b755f11d5 --- /dev/null +++ b/editor/ui/src/lib/api.ts @@ -0,0 +1,260 @@ +/** + * Typed calls into the editor worker. + * + * The page is a *view* over the worker's workspace, not the owner of it. Which + * files are open, which folders are expanded and which root is active all live + * in the worker (backed by the `state` worker), so an agent sees the same + * workspace this page does and a reload does not lose it. + * + * Everything here is `editor::*`. Browsing delegates to shell inside the + * worker rather than from the browser, so the jail decision stays in one place. + */ + +import type { Host } from '@iii-dev/console-ui' + +export interface Buffer { + path: string + mtime: number + language: string +} + +export interface WorkspaceView { + root: string + buffers: Buffer[] + expanded: string[] +} + +/** + * shell's vocabulary, verbatim: a directory is `dir`, not `folder`. + * Getting this wrong classes every directory as a file, and clicking one + * tries to open it. + */ +export type NodeKind = 'file' | 'dir' | 'symlink' | 'other' + +export interface TreeNode { + name: string + kind: NodeKind + size: number + mtime: number + children?: TreeNode[] +} + +export interface TreeResult { + root: string + path: string + tree: { path: string; root: TreeNode } + expanded: string[] +} + +export interface StatusEntry { + path: string + index: string + worktree: string + staged: boolean + renamed_from: string | null +} + +export interface StatusReport { + branch: string | null + upstream: string | null + ahead: number + behind: number + entries: StatusEntry[] + clean: boolean +} + +export interface OpenResult { + path: string + content: string + language: string + size: number + mtime: number + truncated: boolean +} + +export interface SaveResult { + path: string + saved: boolean + conflict: boolean + mtime: number + disk_mtime: number | null + conflict_patch: string | null + added: number + removed: number + created: boolean +} + +export interface DiffResult { + patch: string + added: number + removed: number + identical: boolean + truncated: boolean +} + +export interface FindMatch { + path: string + score: number + positions: number[] +} + +export interface SearchHit { + line: number + text: string +} + +export interface SearchFile { + path: string + hits: SearchHit[] +} + +export interface SearchResult { + files: SearchFile[] + total: number + truncated: boolean +} + +export type SyncAction = 'fetch' | 'pull' | 'push' + +export interface GitActionResult { + ok?: boolean + committed?: boolean + summary: string + ahead?: number + behind?: number +} + +export interface FindResult { + matches: FindMatch[] + scanned: number + truncated: boolean + from_git: boolean +} + +export function createApi(host: Host) { + const call = (fn: string, payload: Record, timeoutMs = 20_000) => + host.iii.trigger(fn, payload, { timeoutMs }) + + return { + workspace: () => call('editor::workspace::get', {}), + openWorkspace: (root: string) => call('editor::workspace::open', { root }, 30_000), + // Expansion is part of the shared workspace, so toggling a folder is a + // call rather than local state: it survives a reload and both surfaces + // agree on it. + tree: (opts: { path?: string; maxDepth?: number; expand?: string[]; collapse?: string[] } = {}) => + call( + 'editor::tree', + { + ...(opts.path ? { path: opts.path } : {}), + max_depth: opts.maxDepth ?? 4, + ...(opts.expand ? { expand: opts.expand } : {}), + ...(opts.collapse ? { collapse: opts.collapse } : {}), + }, + 30_000, + ), + closeBuffer: (path: string) => + call<{ closed: boolean; root: string; buffers: Buffer[] }>('editor::buffers::close', { + path, + }), + open: (path: string) => call('editor::open', { path }, 30_000), + save: (path: string, content: string, expectedMtime: number | null) => + call( + 'editor::save', + { path, content, ...(expectedMtime === null ? {} : { expected_mtime: expectedMtime }) }, + 30_000, + ), + find: (query: string, limit: number) => call('editor::find', { query, limit }), + diff: (before: string, after: string, path?: string) => + call('editor::diff', { before, after, ...(path ? { path } : {}) }), + status: () => call('editor::git::status', {}), + hunks: (path: string) => + call<{ + path: string + added: number + removed: number + untracked: boolean + patch: string + }>('editor::git::hunks', { path, against: 'head' }), + show: (path: string) => + call<{ path: string; rev: string; content: string; exists: boolean }>('editor::git::show', { + path, + }), + search: (pattern: string, ignoreCase: boolean) => + call('editor::search', { pattern, ignore_case: ignoreCase }, 60_000), + commit: (message: string) => call('editor::git::commit', { message }, 60_000), + sync: (action: SyncAction) => call('editor::git::sync', { action }, 120_000), + stash: (action: 'push' | 'pop') => call('editor::git::stash', { action }, 60_000), + } +} + +export type Api = ReturnType + +/** + * Readable text for a rejected bus call. + * + * The engine rejects with an object (`{code, message}`), and `String(err)` on + * that renders the literal "[object Object]" — the message inside it is the + * part the user needs. + */ +export function errorText(err: unknown): string { + if (typeof err === 'string') return err + if (err instanceof Error) return err.message + if (typeof err === 'object' && err !== null) { + const rec = err as Record + if (typeof rec.message === 'string') return rec.message + try { + return JSON.stringify(err) + } catch { + return 'unknown error' + } + } + return String(err) +} + +/** True when the failure is "this directory is not a git repository". */ +export function isNotARepo(message: string): boolean { + return message.includes('not a git repository') +} + +export interface FlatNode { + path: string + name: string + kind: NodeKind + depth: number +} + +/** + * Flatten the tree into visible rows, honouring `expanded`. + * + * A collapsed folder contributes its own row and nothing beneath it — that is + * what keeps the tree navigable in a large repo instead of a thousand-row dump. + */ +export function isDir(node: { kind: NodeKind; children?: unknown }): boolean { + return node.kind === 'dir' || node.children !== undefined +} + +export function visibleRows(root: TreeNode, expanded: Set): FlatNode[] { + const out: FlatNode[] = [] + const walk = (node: TreeNode, prefix: string, depth: number) => { + const children = [...(node.children ?? [])].sort((a, b) => { + const dirA = isDir(a) + if (dirA !== isDir(b)) return dirA ? -1 : 1 + return a.name.localeCompare(b.name) + }) + for (const child of children) { + const path = prefix ? `${prefix}/${child.name}` : child.name + out.push({ path, name: child.name, kind: child.kind, depth }) + if (isDir(child) && expanded.has(path)) walk(child, path, depth + 1) + } + } + walk(root, '', 0) + return out +} + +/** Status vocabulary reduced to the one letter a gutter shows. */ +export function statusMark(entry: StatusEntry): string { + const label = entry.worktree !== 'unchanged' ? entry.worktree : entry.index + if (label === 'untracked') return '?' + if (label === 'conflicted') return '!' + return label.charAt(0).toUpperCase() +} diff --git a/editor/ui/src/page/index.tsx b/editor/ui/src/page/index.tsx new file mode 100644 index 000000000..c091742ae --- /dev/null +++ b/editor/ui/src/page/index.tsx @@ -0,0 +1,878 @@ +/** + * The `#/ext/editor` page: a file tree, tabs, and the shared Monaco editor. + * + * A **folder** is the unit, not a repository — tree, tabs, editor and search + * all work in a plain directory, and git only adds a branch label, change + * marks and the action strip when the root happens to be a repo. + * + * The workspace itself lives in the worker, not here. This page reads it and + * writes to it, which makes it a second view rather than a second editor: an + * agent that opens a file puts a tab on your screen, and closing this tab + * closes it for the agent too. + * + * Layout note: this page shares the viewport with the console's chat pane, so + * it gets about half the window. The sidebar is deliberately narrow and + * collapsible — at these widths, every pixel spent on chrome is taken from the + * code. + * + * Chrome note: the shared `CodeEditor` is intentionally bare (no line numbers, + * no glyph margin, no minimap), and the SOP forbids bundling another editor. + * So the page carries the chrome instead — the status line below the editor is + * where the language, size and git deltas live, which is the honest substitute + * for a gutter we cannot paint. + */ + +import { + Badge, + Button, + CodeEditor, + CodeHighlight, + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + EmptyState, + type Host, + Input, + StatusDot, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { + type Buffer, + createApi, + errorText, + isDir, + isNotARepo, + type SaveResult, + type SearchResult, + type StatusEntry, + type StatusReport, + type SyncAction, + statusMark, + type TreeNode, + visibleRows, +} from '../lib/api' + +const POLL_MS = 3_000 +/** How long a row keeps its "just changed" accent after an edit lands. */ +const FLASH_MS = 12_000 + +/** Editor contents per open path. The worker owns *which* paths are open; the + * text being typed is the one thing genuinely local until it is saved. */ +interface Draft { + base: string + draft: string + truncated: boolean + stale: boolean +} + +interface Delta { + added: number + removed: number + untracked: boolean + patch: string +} + +export function EditorPage({ host }: { host: Host }) { + const api = useMemo(() => createApi(host), [host]) + + const [root, setRoot] = useState('') + const [rootInput, setRootInput] = useState('') + const [rootOpen, setRootOpen] = useState(false) + const [sideOpen, setSideOpen] = useState(true) + const [buffers, setBuffers] = useState([]) + const [expanded, setExpanded] = useState>(new Set()) + const [treeRoot, setTreeRoot] = useState(null) + const [drafts, setDrafts] = useState>({}) + const [activePath, setActivePath] = useState(null) + const [view, setView] = useState<'edit' | 'diff' | 'git'>('edit') + const [mode, setMode] = useState<'files' | 'search'>('files') + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [searchResult, setSearchResult] = useState(null) + const [status, setStatus] = useState(null) + const [noRepo, setNoRepo] = useState(false) + const [delta, setDelta] = useState(null) + const [flashed, setFlashed] = useState>({}) + const [conflict, setConflict] = useState(null) + const [commitMessage, setCommitMessage] = useState('') + const [gitNote, setGitNote] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + // Read inside the poll without making it a dependency — rebuilding the + // interval on every keystroke would reset the timer and stall the feed. + const draftsRef = useRef(drafts) + draftsRef.current = drafts + const seenRef = useRef>({}) + + const activeBuffer = buffers.find((b) => b.path === activePath) ?? null + const activeDraft = activePath ? (drafts[activePath] ?? null) : null + const dirty = activeDraft !== null && activeDraft.draft !== activeDraft.base + + const applyWorkspace = useCallback((ws: { root: string; buffers: Buffer[]; expanded: string[] }) => { + setRoot(ws.root) + setBuffers(ws.buffers) + setExpanded(new Set(ws.expanded)) + }, []) + + const loadTree = useCallback( + async (opts: { expand?: string[]; collapse?: string[] } = {}) => { + try { + const result = await api.tree(opts) + setTreeRoot(result.tree.root) + setRoot(result.root) + setExpanded(new Set(result.expanded)) + setError(null) + } catch (e) { + setError(errorText(e)) + } + }, + [api], + ) + + useEffect(() => { + let cancelled = false + api + .workspace() + .then((ws) => { + if (cancelled) return + applyWorkspace(ws) + setRootInput(ws.root) + if (ws.buffers.length > 0) setActivePath(ws.buffers[ws.buffers.length - 1].path) + }) + .catch((e) => { + if (!cancelled) setError(errorText(e)) + }) + void loadTree() + return () => { + cancelled = true + } + }, [api, applyWorkspace, loadTree]) + + /** Pull contents for any buffer we do not have text for yet. */ + const hydrate = useCallback( + async (list: Buffer[]) => { + for (const buffer of list) { + if (draftsRef.current[buffer.path]) continue + try { + const file = await api.open(buffer.path) + setDrafts((prev) => ({ + ...prev, + [buffer.path]: { + base: file.content, + draft: file.content, + truncated: file.truncated, + stale: false, + }, + })) + } catch { + // A buffer we cannot read keeps its tab; the error shows on select. + } + } + }, + [api], + ) + + useEffect(() => { + void hydrate(buffers) + }, [buffers, hydrate]) + + const openPath = useCallback( + async (path: string) => { + setError(null) + try { + const file = await api.open(path) + setDrafts((prev) => ({ + ...prev, + [path]: { + base: file.content, + draft: file.content, + truncated: file.truncated, + stale: false, + }, + })) + applyWorkspace(await api.workspace()) + setActivePath(path) + setView('edit') + } catch (e) { + setError(errorText(e)) + } + }, + [api, applyWorkspace], + ) + + const toggleFolder = useCallback( + (path: string) => { + // The worker owns expansion (it collapses descendants with the parent), + // so the toggle is a call and the response is the truth. + void loadTree(expanded.has(path) ? { collapse: [path] } : { expand: [path] }) + }, + [expanded, loadTree], + ) + + /** git is an overlay — a folder with no repository is not an error state. */ + const refreshGit = useCallback(async () => { + try { + const report = await api.status() + setStatus(report) + setNoRepo(false) + + const now = Date.now() + const seen = seenRef.current + const fresh: Record = {} + const nextSeen: Record = {} + for (const entry of report.entries) { + const signature = `${entry.index}/${entry.worktree}` + nextSeen[entry.path] = signature + if (seen[entry.path] !== undefined && seen[entry.path] !== signature) { + fresh[entry.path] = now + } + } + seenRef.current = nextSeen + if (Object.keys(fresh).length > 0) setFlashed((prev) => ({ ...prev, ...fresh })) + } catch (e) { + const message = errorText(e) + setStatus(null) + setNoRepo(isNotARepo(message)) + if (!isNotARepo(message)) setError(message) + } + }, [api]) + + /** Pull open tabs forward when their file moved on disk. */ + const refreshBuffers = useCallback(async () => { + try { + const ws = await api.workspace() + setBuffers(ws.buffers) + for (const buffer of ws.buffers) { + const local = draftsRef.current[buffer.path] + if (!local) continue + const file = await api.open(buffer.path) + if (file.content === local.base) continue + const edited = local.draft !== local.base + setDrafts((prev) => ({ + ...prev, + [buffer.path]: edited + ? { ...local, stale: true } + : { base: file.content, draft: file.content, truncated: file.truncated, stale: false }, + })) + setFlashed((prev) => ({ ...prev, [buffer.path]: Date.now() })) + } + } catch { + // Transient; the next tick tries again. + } + }, [api]) + + useEffect(() => { + const id = setInterval(() => { + void refreshGit() + void refreshBuffers() + }, POLL_MS) + void refreshGit() + return () => clearInterval(id) + }, [refreshGit, refreshBuffers]) + + useEffect(() => { + if (Object.keys(flashed).length === 0) return + const id = setInterval(() => { + const cutoff = Date.now() - FLASH_MS + setFlashed((prev) => { + const next = Object.fromEntries(Object.entries(prev).filter(([, at]) => at > cutoff)) + return Object.keys(next).length === Object.keys(prev).length ? prev : next + }) + }, 2_000) + return () => clearInterval(id) + }, [flashed]) + + /** The status line's git deltas — the gutter we cannot paint. */ + useEffect(() => { + if (activePath === null || noRepo) { + setDelta(null) + return + } + let cancelled = false + api + .hunks(activePath) + .then((h) => { + if (!cancelled) + setDelta({ + added: h.added, + removed: h.removed, + untracked: h.untracked, + patch: h.patch, + }) + }) + .catch(() => { + if (!cancelled) setDelta(null) + }) + return () => { + cancelled = true + } + }, [activePath, api, noRepo, status]) + + useEffect(() => { + if (mode !== 'files' || query.trim() === '') { + setResults([]) + return + } + let cancelled = false + const id = setTimeout(() => { + api + .find(query, 20) + .then((r) => { + if (!cancelled) setResults(r.matches.map((m) => m.path)) + }) + .catch((e) => { + if (!cancelled) setError(errorText(e)) + }) + }, 120) + return () => { + cancelled = true + clearTimeout(id) + } + }, [api, mode, query]) + + const runSearch = useCallback(async () => { + if (query.trim() === '') return + setBusy(true) + try { + setSearchResult(await api.search(query, true)) + setError(null) + } catch (e) { + setError(errorText(e)) + } finally { + setBusy(false) + } + }, [api, query]) + + const save = useCallback(async () => { + if (!activePath || !activeBuffer || !activeDraft) return + setBusy(true) + try { + const result = await api.save(activePath, activeDraft.draft, activeBuffer.mtime) + if (result.conflict) { + setConflict(result) + } else { + setDrafts((prev) => ({ + ...prev, + [activePath]: { ...activeDraft, base: activeDraft.draft, stale: false }, + })) + applyWorkspace(await api.workspace()) + void refreshGit() + } + } catch (e) { + setError(errorText(e)) + } finally { + setBusy(false) + } + }, [activeBuffer, activeDraft, activePath, api, applyWorkspace, refreshGit]) + + const closeTab = useCallback( + async (path: string) => { + try { + const result = await api.closeBuffer(path) + setBuffers(result.buffers) + setDrafts((prev) => { + const next = { ...prev } + delete next[path] + return next + }) + setActivePath((current) => + current === path ? (result.buffers[result.buffers.length - 1]?.path ?? null) : current, + ) + } catch (e) { + setError(errorText(e)) + } + }, + [api], + ) + + const changeRoot = useCallback(async () => { + if (rootInput.trim() === '') return + try { + const ws = await api.openWorkspace(rootInput.trim()) + applyWorkspace(ws) + setDrafts({}) + setActivePath(ws.buffers[ws.buffers.length - 1]?.path ?? null) + setRootOpen(false) + setError(null) + await loadTree() + void refreshGit() + } catch (e) { + setError(errorText(e)) + } + }, [api, applyWorkspace, loadTree, refreshGit, rootInput]) + + const gitAction = useCallback( + async (run: () => Promise<{ summary: string }>) => { + setBusy(true) + try { + setGitNote((await run()).summary || 'done') + void refreshGit() + } catch (e) { + setGitNote(errorText(e)) + } finally { + setBusy(false) + } + }, + [refreshGit], + ) + + const marks = useMemo(() => { + const map = new Map() + for (const entry of status?.entries ?? []) map.set(entry.path, entry) + return map + }, [status]) + + const rows = useMemo(() => (treeRoot ? visibleRows(treeRoot, expanded) : []), [treeRoot, expanded]) + + const lineCount = activeDraft ? activeDraft.draft.split('\n').length : 0 + + return ( +
+
+ editor + {rootOpen ? ( + <> + + { + if (e.key === 'Enter') void changeRoot() + }} + preserveCase + /> + + + + + ) : ( + <> + + + + )} +
+ +
+ {sideOpen && ( + + )} + +
+ {buffers.length === 0 ? ( + + ) : ( + <> +
+ {buffers.map((buffer) => { + const local = drafts[buffer.path] + return ( +
+ + {local && local.draft !== local.base && } + +
+ ) + })} +
+ + {activePath && activeDraft && ( + <> +
+
+ + + +
+ + {activeDraft.stale && disk moved} + +
+ + {activeDraft.truncated && ( +
+ Larger than max_file_bytes — only the beginning was read, so saving is refused. +
+ )} + + {view === 'edit' ? ( +
+ + setDrafts((prev) => ({ + ...prev, + [activePath]: { ...activeDraft, draft: next }, + })) + } + /> +
+ ) : view === 'diff' ? ( + + ) : ( + + )} + +
+ + {activePath} + + {activeBuffer?.language ?? 'plaintext'} + {lineCount} ln + {delta && + (delta.untracked ? ( + new + ) : ( + (delta.added > 0 || delta.removed > 0) && ( + + +{delta.added}{' '} + −{delta.removed} + + ) + ))} + {dirty ? 'unsaved' : 'saved'} +
+ + )} + + )} +
+
+ + {status !== null && ( +
+ + + {status.branch ?? 'detached'} + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && `↑${status.ahead}`} + {status.behind > 0 && `↓${status.behind}`} + + )} + + + + + + {(['fetch', 'pull', 'push'] as SyncAction[]).map((action) => ( + + ))} + + +
+ )} + + {gitNote && ( +
+ {/* A plain div, not a button: git's output is the thing you most + want to select and copy, and wrapping it in a click target makes + that impossible. The dismiss affordance is its own control. */} +
{gitNote}
+ +
+ )} + {noRepo && !error &&
not a git repository — tree and editor still work
} + {error &&
{error}
} + + !open && setConflict(null)}> + + This file changed while you were editing it + + Nothing was written. Below is the difference between what is on disk now and what you tried to save. + + +
+ + +
+
+
+
+ ) +} + +/** Rendered lines a patch may contribute before it is cut short. */ +const MAX_PATCH_LINES = 600 + +/** + * A unified patch, rendered in the worker. + * + * Deliberately hand-rolled rather than borrowed from the console. The console's + * own diff cards are backed by a library that weighs megabytes; a worker asset + * is capped at 8 MiB and bundling a second copy of it measured at 10.3 MB, so + * the only way to share it would be to widen `@iii-dev/console-ui`. This page + * is not worth changing the shared contract for. + * + * What it gives up is syntax highlighting inside the diff. What it keeps is the + * part that makes a diff readable: one row per line, added and removed lines + * banded rather than marked by a leading character, hunk headers separating + * them, and the file's real line numbers down the left — taken from the `@@` + * headers, not counted off the patch rows. + */ +function DiffPane({ path, patch }: { path: string; patch: string }) { + if (patch.trim() === '') return
no changes
+ + const all = patch.split('\n') + const shown = all.slice(0, MAX_PATCH_LINES) + const overflow = all.length - shown.length + + let lineNo = 0 + const rows = shown.map((line, index) => { + let kind = 'ctx' + let num: number | null = null + if (line.startsWith('@@')) { + kind = 'hunk' + const m = /\+(\d+)/.exec(line.split('@@')[1] ?? '') + lineNo = m ? Number(m[1]) : lineNo + } else if ( + line.startsWith('+++') || + line.startsWith('---') || + line.startsWith('diff ') || + line.startsWith('index ') + ) { + kind = 'meta' + } else if (line.startsWith('+')) { + kind = 'add' + num = lineNo++ + } else if (line.startsWith('-')) { + kind = 'del' + } else { + num = lineNo++ + } + return { line, kind, num, key: `${index}` } + }) + + return ( +
+
+ {path} +
+
+ {rows.map((row) => ( +
+ {row.num ?? ''} + {row.line || ' '} +
+ ))} + {overflow > 0 &&
… {overflow} more lines (truncated)
} +
+
+ ) +} + +/** The buffer against its last read: what saving would write. */ +function LocalDiff({ host, path, base, draft }: { host: Host; path: string; base: string; draft: string }) { + const api = useMemo(() => createApi(host), [host]) + const [patch, setPatch] = useState(null) + + useEffect(() => { + let cancelled = false + api + .diff(base, draft, path) + .then((r) => { + if (!cancelled) setPatch(r.identical ? '' : r.patch) + }) + .catch(() => { + if (!cancelled) setPatch(null) + }) + return () => { + cancelled = true + } + }, [api, base, draft, path]) + + if (patch === null) return
computing…
+ return +} diff --git a/editor/ui/styles.css b/editor/ui/styles.css new file mode 100644 index 000000000..28da32f80 --- /dev/null +++ b/editor/ui/styles.css @@ -0,0 +1,596 @@ +/* + * Every rule is scoped under [data-iii-ui="editor"] — the console mounts each + * injected render inside that wrapper, and injected CSS is unlayered, so an + * unscoped selector would silently outrank the console's own styles + * document-wide. + * + * Colours are design tokens only: dark mode is a variable flip, so a + * token-based rule themes for free and a hardcoded hex does not. + * + * The page shares a viewport with the console's chat pane, so it gets roughly + * half the window. Everything below is sized for that: a 200px sidebar rather + * than 300, 22px rows, 12px monospace. Give the code the room. + */ + +[data-iii-ui='editor'] .ed-root { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--color-bg); + color: var(--color-ink); + font-size: 13px; +} + +/* ---------------------------------------------------------------- header */ + +[data-iii-ui='editor'] .ed-head { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--color-rule); + flex: none; +} + +/* The console prefixes its section headers with a dim `$`; matching it is + most of what makes an injected page read as part of the same product. */ +[data-iii-ui='editor'] .ed-brand { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + letter-spacing: 0.08em; + color: var(--color-ink-faint); + flex: none; +} + +[data-iii-ui='editor'] .ed-brand::before { + content: '$ '; + color: var(--color-accent); +} + +[data-iii-ui='editor'] .ed-rootpath { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + color: var(--color-ink-faint); +} + +[data-iii-ui='editor'] .ed-icon { + flex: none; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid var(--color-rule); + border-radius: 4px; + background: transparent; + color: var(--color-ink-faint); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +[data-iii-ui='editor'] .ed-icon:hover { + background: var(--color-panel); + color: var(--color-ink); +} + +/* ------------------------------------------------------------------ body */ + +[data-iii-ui='editor'] .ed-body { + display: flex; + flex: 1; + min-height: 0; +} + +[data-iii-ui='editor'] .ed-side { + width: 200px; + flex: none; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-right: 1px solid var(--color-rule); + min-height: 0; +} + +[data-iii-ui='editor'] .ed-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ------------------------------------------------- segmented mode switch */ + +[data-iii-ui='editor'] .ed-seg { + display: flex; + border: 1px solid var(--color-rule); + border-radius: 4px; + overflow: hidden; + flex: none; +} + +[data-iii-ui='editor'] .ed-seg button { + flex: 1; + padding: 4px 6px; + border: 0; + background: transparent; + color: var(--color-ink-faint); + font: inherit; + font-size: 11px; + letter-spacing: 0.04em; + white-space: nowrap; + cursor: pointer; +} + +/* Active reads as the nav tabs do: ink fill, paper text. */ +[data-iii-ui='editor'] .ed-seg button[data-active='true'] { + background: var(--color-ink); + color: var(--color-bg); +} + +/* -------------------------------------------------------------- the tree */ + +[data-iii-ui='editor'] .ed-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0 -4px; +} + +[data-iii-ui='editor'] .ed-list { + list-style: none; + margin: 0; + padding: 0; +} + +[data-iii-ui='editor'] .ed-row { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + height: 22px; + padding: 0 6px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--color-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +[data-iii-ui='editor'] .ed-row:hover { + background: var(--color-panel); +} + +[data-iii-ui='editor'] .ed-row[data-active='true'] { + background: var(--color-panel); + box-shadow: inset 2px 0 0 var(--color-accent); +} + +/* An edit that just landed. The accent expires on a timer in the page, not in + CSS, so a row that changes twice re-lights instead of finishing its run. */ +[data-iii-ui='editor'] .ed-row[data-fresh='true'] { + animation: ed-land 700ms ease-out; +} + +@keyframes ed-land { + from { + background: var(--color-accent); + color: var(--color-accent-fg); + } + to { + background: transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-iii-ui='editor'] .ed-row[data-fresh='true'] { + animation: none; + box-shadow: inset 2px 0 0 var(--color-accent); + } +} + +[data-iii-ui='editor'] .ed-caret { + flex: none; + width: 10px; + color: var(--color-ink-ghost); + font-size: 9px; +} + +[data-iii-ui='editor'] .ed-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-iii-ui='editor'] .ed-dir { + color: var(--color-ink-faint); +} + +/* One-letter git mark, the vocabulary git itself uses. */ +[data-iii-ui='editor'] .ed-mark { + flex: none; + width: 1em; + text-align: center; + font-size: 10.5px; + font-weight: 600; +} + +[data-iii-ui='editor'] .ed-mark[data-s='modified'] { + color: var(--color-warn); +} +[data-iii-ui='editor'] .ed-mark[data-s='added'], +[data-iii-ui='editor'] .ed-mark[data-s='untracked'] { + color: var(--color-ok); +} +[data-iii-ui='editor'] .ed-mark[data-s='deleted'], +[data-iii-ui='editor'] .ed-mark[data-s='conflicted'] { + color: var(--color-alert); +} + +[data-iii-ui='editor'] .ed-hint { + padding: 6px; + color: var(--color-ink-faint); + font-size: 11.5px; +} + +[data-iii-ui='editor'] .ed-count { + flex: none; + color: var(--color-ink-ghost); + font-size: 10.5px; +} + +/* ------------------------------------------------------------------ tabs */ + +[data-iii-ui='editor'] .ed-tabs { + display: flex; + align-items: stretch; + gap: 1px; + height: 30px; + flex: none; + overflow-x: auto; + border-bottom: 1px solid var(--color-rule); + scrollbar-width: none; +} + +[data-iii-ui='editor'] .ed-tabs::-webkit-scrollbar { + display: none; +} + +[data-iii-ui='editor'] .ed-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px 0 10px; + border-bottom: 2px solid transparent; + white-space: nowrap; +} + +[data-iii-ui='editor'] .ed-tab[data-active='true'] { + background: var(--color-panel); + border-bottom-color: var(--color-accent); +} + +[data-iii-ui='editor'] .ed-tab-name { + border: 0; + background: transparent; + color: var(--color-ink-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + cursor: pointer; + padding: 0; +} + +[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-tab-name { + color: var(--color-ink); +} + +[data-iii-ui='editor'] .ed-dot { + color: var(--color-accent); + font-size: 14px; + line-height: 1; +} + +[data-iii-ui='editor'] .ed-close { + border: 0; + background: transparent; + color: var(--color-ink-ghost); + font-size: 13px; + line-height: 1; + padding: 0 2px; + cursor: pointer; + visibility: hidden; +} + +[data-iii-ui='editor'] .ed-tab:hover .ed-close, +[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close { + visibility: visible; +} + +[data-iii-ui='editor'] .ed-close:hover { + color: var(--color-alert); +} + +/* ---------------------------------------------------------- editor + bar */ + +[data-iii-ui='editor'] .ed-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + flex: none; +} + +[data-iii-ui='editor'] .ed-spacer { + flex: 1; +} + +[data-iii-ui='editor'] .ed-surface { + flex: 1; + min-height: 0; + margin: 0 8px; + border: 1px solid var(--color-rule); + border-radius: 4px; + overflow: hidden; +} + +[data-iii-ui='editor'] .ed-patch { + flex: 1; + min-height: 0; + overflow: auto; + margin: 0 8px; + border: 1px solid var(--color-rule); + border-radius: 4px; +} + +/* The status line carries what a gutter would have: the shared CodeEditor is + deliberately chrome-less (no line numbers, no glyph margin), so the file's + language, size and git deltas are surfaced here instead. */ +[data-iii-ui='editor'] .ed-status { + display: flex; + align-items: center; + gap: 10px; + height: 24px; + padding: 0 10px; + flex: none; + border-top: 1px solid var(--color-rule); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--color-ink-faint); +} + +[data-iii-ui='editor'] .ed-status-path { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-ink); +} + +[data-iii-ui='editor'] .ed-add { + color: var(--color-ok); +} +[data-iii-ui='editor'] .ed-del { + color: var(--color-alert); +} +[data-iii-ui='editor'] .ed-unsaved { + color: var(--color-accent); +} + +/* ------------------------------------------------------------- git strip */ + +/* The strip has to survive a narrow pane: this page shares the viewport with + the chat column, so the actions wrap rather than clip. */ +[data-iii-ui='editor'] .ed-git { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + padding: 6px 10px; + flex: none; + border-top: 1px solid var(--color-rule); + background: var(--color-panel); +} + +[data-iii-ui='editor'] .ed-branch { + display: flex; + align-items: center; + gap: 5px; + flex: none; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} + +[data-iii-ui='editor'] .ed-ab { + color: var(--color-ink-faint); + font-size: 10.5px; +} + +[data-iii-ui='editor'] .ed-commit { + flex: 1 1 120px; + min-width: 100px; +} + +[data-iii-ui='editor'] .ed-gitnote { + position: relative; + display: flex; + align-items: flex-start; + gap: 6px; + padding: 5px 10px; + border-top: 1px solid var(--color-rule); + flex: none; + max-height: 96px; +} + +[data-iii-ui='editor'] .ed-gitnote-text { + flex: 1; + min-width: 0; + margin: 0; + overflow: auto; + max-height: 84px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--color-ink-faint); + white-space: pre-wrap; +} + +[data-iii-ui='editor'] .ed-gitnote-x { + flex: none; + border: 1px solid var(--color-rule); + border-radius: 4px; + background: transparent; + color: var(--color-ink-faint); + font-size: 12px; + line-height: 1; + width: 20px; + height: 20px; + cursor: pointer; +} + +[data-iii-ui='editor'] .ed-gitnote-x:hover { + background: var(--color-panel); + color: var(--color-ink); +} + +[data-iii-ui='editor'] .ed-error { + padding: 6px 10px; + flex: none; + border-top: 1px solid var(--color-alert); + color: var(--color-ink); + font-size: 11.5px; + white-space: pre-wrap; +} + +[data-iii-ui='editor'] .ed-warn { + margin: 6px 8px 0; + padding: 5px 8px; + border: 1px solid var(--color-warn); + border-radius: 4px; + font-size: 11.5px; +} + +/* --------------------------------------------------- chat + trace cards */ + +[data-iii-ui='editor'] .ed-card { + display: flex; + flex-direction: column; + gap: 5px; + padding: 7px 9px; + border: 1px solid var(--color-rule); + border-radius: 5px; + background: var(--color-panel); + font-size: 12px; +} + +[data-iii-ui='editor'] .ed-card-head { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} + +[data-iii-ui='editor'] .ed-card-path { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-iii-ui='editor'] .ed-stat { + display: inline-flex; + gap: 5px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +[data-iii-ui='editor'] .ed-card-patch { + max-height: 380px; + overflow: auto; + border-top: 1px solid var(--color-rule); + padding-top: 5px; +} + +[data-iii-ui='editor'] .ed-hits { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} + +[data-iii-ui='editor'] .ed-muted { + color: var(--color-ink-faint); +} + +/* ------------------------------------------------------------ patch view */ + +[data-iii-ui='editor'] .ed-patch-head { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-bottom: 1px solid var(--color-rule); + background: var(--color-panel); +} + +[data-iii-ui='editor'] .ed-patch-body { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + line-height: 1.5; +} + +[data-iii-ui='editor'] .ed-dline { + display: flex; + gap: 8px; + padding: 0 8px; + white-space: pre; +} + +[data-iii-ui='editor'] .ed-dnum { + flex: none; + width: 3ch; + text-align: right; + color: var(--color-ink-ghost); + user-select: none; +} + +[data-iii-ui='editor'] .ed-dtext { + flex: 1; + min-width: 0; + overflow-x: auto; +} + +/* Banded rows, not a coloured leading character — colour-blind readers get the + +/- glyph, everyone else gets the band. */ +[data-iii-ui='editor'] .ed-dline[data-k='add'] { + background: color-mix(in srgb, var(--color-ok) 16%, transparent); +} + +[data-iii-ui='editor'] .ed-dline[data-k='del'] { + background: color-mix(in srgb, var(--color-alert) 14%, transparent); +} + +[data-iii-ui='editor'] .ed-dline[data-k='hunk'] { + color: var(--color-ink-faint); + background: var(--color-panel); +} + +[data-iii-ui='editor'] .ed-dline[data-k='meta'] { + color: var(--color-ink-ghost); +} diff --git a/editor/ui/tsconfig.json b/editor/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/editor/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +} diff --git a/iii-permissions.yaml b/iii-permissions.yaml index d59e2a674..f78abbcb3 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -83,6 +83,7 @@ rules: # change cannot inadvertently re-open the injection path. - '!storage::on-config-change' - '!database::on-config-change' + - '!editor::on-config-change' - '!session::on-config-change' # (coder::on-config-change retired: the code surface folded into the shell # worker, whose shell::on-config-change is already denied above.) @@ -201,6 +202,26 @@ rules: - worktree::get - worktree::status - worktree::validate + # editor: the reading half. editor::diff is pure (two strings in, a patch + # out — no I/O at all); the rest read through the shell worker, so they reach + # nothing shell::fs::read and shell::exec could not already reach. + # editor::open is allowed even though it records a buffer: recording what an + # agent read is the point of the shared workspace, and gating the only read + # path would make the worker unusable. + # Left at the needs_approval default on purpose: editor::save, ::create, + # ::delete and ::move write to disk; the editor::git write surface + # (commit / sync / stash / undo-commit) rewrites history or talks to a + # remote; editor::workspace::open repoints the workspace for every surface + # at once; editor::buffers::close discards someone else's tab. + - editor::diff + - editor::open + - editor::tree + - editor::find + - editor::search + - editor::workspace::get + - editor::buffers::list + - editor::git::status + - editor::git::hunks # Read-only code surface (coder::*, now served by the shell worker). # Mutating ops (create/update/move/delete-file) stay approval-gated. - coder::info diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcac8c9af..73350d9a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,22 @@ importers: specifier: ^5.9.2 version: 5.9.3 + editor/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + eval/ui: dependencies: '@iii-dev/console-ui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 803c50e7f..70b3c0f4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ packages: - console/ui - browser/ui - database/ui + - editor/ui - eval/ui - memory/ui - state/ui From b5398f37a9ca1ffb88a76b4d4bbc608ba65ab068 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 29 Jul 2026 17:10:05 +0100 Subject: [PATCH 02/11] (MOT-4274) fix(editor): address review findings Unmerged porcelain v2 entries were never reported. The `u` line carries nine columns before the path, not ten, so every conflicted file was silently dropped from the status. Covered by a test. The patch cap called String::truncate on a byte index, which panics the moment a diff contains a multibyte character and the cap lands inside it. Cut back to the last char boundary instead. editor::git::hunks hard-coded three context lines rather than reading the configured diff_context_lines, and its schema documented the shipped default instead of the setting. git_timeout_ms was copied into the bus at boot, making it the one field that ignored a configuration change despite the docs promising every field hot-reloads. It is now shared through an atomic the reload path publishes to. A failed configuration trigger bind was a warning, which left the worker serving requests with limits that could never change. It is fatal now. editor::delete discarded a collapse when no buffer happened to close, because the save was gated on the buffer list rather than on the record. The read path claimed to check size before pulling bytes but streamed the whole file and truncated afterwards. It now stops draining once past the cap. The tree walk bounded emitted files but not visited nodes, so a deep tree of empty directories was traversed in full regardless of the limit. The integration test could leak an engine process when the worker failed to spawn, because nothing owned the child until both had started. Tab close buttons used visibility:hidden, which removes them from the focus order, so an inactive tab could not be closed from the keyboard. Also removes a stray line that a test write left in the worker README, and corrects a stale function count in the schema test's module doc. --- editor/README.md | 2 -- editor/src/bus.rs | 35 ++++++++++++++----- editor/src/configuration.rs | 21 ++++++++--- editor/src/functions/mod.rs | 23 ++++++++++-- editor/src/functions/types.rs | 13 +++++-- editor/src/git.rs | 20 ++++++++--- editor/src/main.rs | 17 +++++---- editor/src/tree.rs | 26 +++++++++++--- .../golden/schemas/editor.git.hunks.json | 2 +- editor/tests/golden/schemas/editor.save.json | 2 +- editor/tests/integration.rs | 19 ++++++---- editor/tests/schemas.rs | 2 +- editor/ui/styles.css | 10 +++--- 13 files changed, 142 insertions(+), 50 deletions(-) diff --git a/editor/README.md b/editor/README.md index 25d35e51d..79c71a8be 100644 --- a/editor/README.md +++ b/editor/README.md @@ -1,7 +1,5 @@ # editor -A line an agent wrote through shell::fs::write, without ever calling editor::*. - A code workspace that an agent and a person share. Open a folder, and the buffers you have open, the folders you have expanded, and the mtimes each buffer was read at are one record on the bus — so the file an agent opens diff --git a/editor/src/bus.rs b/editor/src/bus.rs index 2026ae734..4d4abaf71 100644 --- a/editor/src/bus.rs +++ b/editor/src/bus.rs @@ -12,6 +12,7 @@ //! jail-escape from `shell` must read as a jail-escape to the caller, not as //! an `editor` error. +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use iii_sdk::channels::{ChannelReader, StreamChannelRef}; @@ -64,11 +65,14 @@ pub struct Bus { /// Engine WebSocket base, needed to open the read channel `shell::fs::read` /// hands back. Same string the binary was started with. ws_url: String, - git_timeout_ms: u64, + /// Shared with the configuration reload path rather than copied: every + /// other limit is read from the live snapshot per call, and a timeout + /// frozen at boot would be the one field that quietly ignored an edit. + git_timeout_ms: Arc, } impl Bus { - pub fn new(iii: Arc, ws_url: String, git_timeout_ms: u64) -> Self { + pub fn new(iii: Arc, ws_url: String, git_timeout_ms: Arc) -> Self { Self { iii, ws_url, @@ -76,6 +80,10 @@ impl Bus { } } + fn git_timeout(&self) -> u64 { + self.git_timeout_ms.load(Ordering::Relaxed) + } + async fn call( &self, function_id: &str, @@ -101,13 +109,13 @@ impl Bus { let mut payload = json!({ "command": "git", "args": args, - "timeout_ms": self.git_timeout_ms, + "timeout_ms": self.git_timeout(), }); if let Some(dir) = cwd { payload["cwd"] = json!(dir); } let value = self - .call("shell::exec", payload, self.git_timeout_ms + 5_000) + .call("shell::exec", payload, self.git_timeout() + 5_000) .await?; serde_json::from_value(value) .map_err(|e| Error::Handler(format!("shell::exec returned an unexpected shape: {e}"))) @@ -120,7 +128,7 @@ impl Bus { return Err(Error::Handler(format!( "git {} timed out after {}ms", args.join(" "), - self.git_timeout_ms + self.git_timeout() ))); } if out.exit_code != Some(0) { @@ -152,9 +160,9 @@ impl Bus { /// Read a file's text through the channel `shell::fs::read` returns. /// - /// The size check runs on the stat that comes back with the channel ref, - /// before a byte is pulled, so an oversized file costs one round trip - /// rather than streaming megabytes we intend to throw away. + /// The cap is enforced while draining, not after: the reader stops pulling + /// once it holds more than `max_bytes`, so an oversized file costs one + /// chunk past the limit rather than its whole length in memory. pub async fn read(&self, path: &str, max_bytes: usize) -> Result { let value = self .call("shell::fs::read", json!({ "path": path }), 30_000) @@ -173,7 +181,16 @@ impl Bus { })?; let reader = ChannelReader::new(&self.ws_url, &channel_ref); - let bytes = reader.read_all().await?; + let mut bytes: Vec = Vec::new(); + // Drain chunk by chunk so a huge file cannot be materialised in full + // just to be discarded. One chunk of overshoot is enough to know the + // file exceeded the cap. + while let Some(chunk) = reader.next_binary().await? { + bytes.extend_from_slice(&chunk); + if bytes.len() > max_bytes { + break; + } + } let _ = reader.close().await; let truncated = bytes.len() > max_bytes; diff --git a/editor/src/configuration.rs b/editor/src/configuration.rs index 8c1ff8b8b..28c2cdb04 100644 --- a/editor/src/configuration.rs +++ b/editor/src/configuration.rs @@ -9,6 +9,7 @@ //! every handler reads the live snapshot per call rather than capturing one at //! registration. Nothing requires a restart. +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -132,7 +133,11 @@ async fn trigger_with_retry( /// Swap the snapshot. Handlers read it per call, so the next invocation of /// every function sees the new values. -pub async fn apply_config(cell: &ConfigCell, cfg: WorkerConfig) { +pub async fn apply_config(cell: &ConfigCell, git_timeout_ms: &AtomicU64, cfg: WorkerConfig) { + // The bus holds the git timeout separately (it is read on a path that has + // no access to the snapshot), so it has to be published here or it would + // be the one field a reload silently skipped. + git_timeout_ms.store(cfg.git_timeout_ms, Ordering::Relaxed); *cell.write().await = Arc::new(cfg); } @@ -154,16 +159,22 @@ pub struct OnConfigChangeResponse { /// Register the internal config-change handler and bind a `configuration` /// trigger. Registered here rather than in `functions::register_all` so it /// stays off the public `catalog()`. -pub fn register_config_trigger(iii: &IIIClient, cell: ConfigCell) -> Result<(), Error> { +pub fn register_config_trigger( + iii: &IIIClient, + cell: ConfigCell, + git_timeout_ms: Arc, +) -> Result<(), Error> { let cell_for_fn = cell.clone(); + let timeout_for_fn = git_timeout_ms.clone(); let engine = iii.clone(); iii.register_function( CONFIG_FN_ID, RegisterFunction::new_async(move |_event: OnConfigChangeEvent| { let cell = cell_for_fn.clone(); + let timeout = timeout_for_fn.clone(); let engine = engine.clone(); async move { - on_config_change(&engine, &cell).await; + on_config_change(&engine, &cell, &timeout).await; Ok::(OnConfigChangeResponse { ok: true }) } }) @@ -193,10 +204,10 @@ pub fn register_config_trigger(iii: &IIIClient, cell: ConfigCell) -> Result<(), /// limits without updating persisted state. A failed fetch keeps the previous /// snapshot (last-good) rather than falling back to defaults, which would /// silently widen every cap. -async fn on_config_change(iii: &IIIClient, cell: &ConfigCell) { +async fn on_config_change(iii: &IIIClient, cell: &ConfigCell, git_timeout_ms: &AtomicU64) { match fetch_config(iii).await { Ok(cfg) => { - apply_config(cell, cfg).await; + apply_config(cell, git_timeout_ms, cfg).await; tracing::info!("editor configuration reloaded"); } Err(e) => tracing::error!( diff --git a/editor/src/functions/mod.rs b/editor/src/functions/mod.rs index e4e1971f1..468e374c0 100644 --- a/editor/src/functions/mod.rs +++ b/editor/src/functions/mod.rs @@ -655,7 +655,12 @@ fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { // `-U0` ranges, a person reading the patch wants surrounding // lines. Defaulting to 3 favours the reader, because the // ranges are still correct — just wider. - let context = format!("-U{}", req.context_lines.unwrap_or(3)); + let context = format!( + "-U{}", + req.context_lines + .map(|c| c as usize) + .unwrap_or(cfg.diff_context_lines) + ); let mut args = vec!["diff", &context, "--no-color"]; match req.against { Against::Worktree => {} @@ -686,9 +691,17 @@ fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { let removed = hunks.iter().map(|h| h.removed).sum(); // Bounded like every other patch this worker emits: a huge // diff must not become an unbounded response. + // `String::truncate` panics on a non-boundary index, and a + // byte cap lands mid-codepoint the moment a diff contains any + // multibyte character. Cut back to the last boundary at or + // before the cap. let mut patch = out.stdout; if patch.len() > cfg.max_diff_bytes { - patch.truncate(cfg.max_diff_bytes); + let mut cut = cfg.max_diff_bytes; + while cut > 0 && !patch.is_char_boundary(cut) { + cut -= 1; + } + patch.truncate(cut); } Ok::<_, Error>(GitHunksOutput { path: req.path, @@ -811,8 +824,12 @@ fn register_delete(iii: &Arc, bus: &Arc) { for path in &closed { session.close(path); } + // `collapse` mutates `expanded` as well, so a delete that closed + // no buffers can still have changed the session. Compare the + // whole record rather than gating on the buffer list. + let before = session.clone(); session.collapse(&req.path); - if !closed.is_empty() { + if !closed.is_empty() || session != before { save_session(&bus, &root, &session).await?; } diff --git a/editor/src/functions/types.rs b/editor/src/functions/types.rs index fe4366840..f5551e84d 100644 --- a/editor/src/functions/types.rs +++ b/editor/src/functions/types.rs @@ -364,9 +364,10 @@ pub struct GitHunksInput { /// Which comparison to make. Defaults to `worktree`. #[serde(default)] pub against: Against, - /// Unchanged lines kept around each hunk in `patch`. Defaults to 3, which - /// reads well. Pass 0 for ranges that match a gutter exactly — with - /// context, a hunk's reported range widens to include it. + /// Unchanged lines kept around each hunk in `patch`. Omitted, it follows + /// the worker's `diff_context_lines` setting. Pass 0 for ranges that match + /// a gutter exactly — with context, a hunk's reported range widens to + /// include it. #[serde(default)] pub context_lines: Option, } @@ -498,6 +499,12 @@ pub struct SaveInput { /// longer matches the file on disk, the write is refused and the /// divergence comes back as a patch. Omit only when deliberately /// overwriting whatever is there. + /// + /// Resolution is one second, which is what the filesystem reports. Two + /// writes inside the same second are therefore indistinguishable, and the + /// second one wins silently. The guard is built for the case it actually + /// sees, a person and an agent editing minutes apart, not for concurrent + /// writers racing on the same file. #[serde(default)] pub expected_mtime: Option, } diff --git a/editor/src/git.rs b/editor/src/git.rs index d5a2ca96a..8d1496968 100644 --- a/editor/src/git.rs +++ b/editor/src/git.rs @@ -114,10 +114,11 @@ pub fn parse_status(stdout: &str) -> StatusReport { renamed_from: None, }); } else if let Some(rest) = line.strip_prefix("u ") { - // Unmerged. Same leading shape as an ordinary line but with three - // extra mode/hash columns before the path. - let fields: Vec<&str> = rest.splitn(11, ' ').collect(); - if let Some(path) = fields.get(10) { + // Unmerged: `

` + // — nine columns before the path, not ten. Reading one field too far + // meant conflicted entries were silently dropped from the status. + let fields: Vec<&str> = rest.splitn(10, ' ').collect(); + if let Some(path) = fields.get(9) { report.entries.push(StatusEntry { path: (*path).to_string(), index: "conflicted".to_string(), @@ -305,6 +306,17 @@ mod tests { assert!(r.clean); } + #[test] + fn unmerged_entries_are_reported_as_conflicted() { + // Nine columns then the path, per porcelain v2. + let out = "u UU N... 100644 100644 100644 100644 aaa bbb ccc src/conflict.rs\n"; + let r = parse_status(out); + assert_eq!(r.entries.len(), 1, "an unmerged entry must not be dropped"); + assert_eq!(r.entries[0].path, "src/conflict.rs"); + assert_eq!(r.entries[0].worktree, "conflicted"); + assert!(!r.clean); + } + #[test] fn malformed_line_is_skipped_not_fatal() { let r = parse_status("1 broken\n? real.rs\n"); diff --git a/editor/src/main.rs b/editor/src/main.rs index 3bc32c2f7..864d054db 100644 --- a/editor/src/main.rs +++ b/editor/src/main.rs @@ -86,20 +86,25 @@ async fn main() -> Result<()> { let cfg = configuration::fetch_config(&iii) .await .map_err(|e| anyhow::anyhow!("loading editor configuration: {e}"))?; - let git_timeout_ms = cfg.git_timeout_ms; + let git_timeout_ms = Arc::new(std::sync::atomic::AtomicU64::new(cfg.git_timeout_ms)); let cfg = configuration::cell(cfg); // The bus carries the engine URL because `shell::fs::read` answers with a // channel reference that has to be dialled separately. - let bus = Arc::new(Bus::new(iii.clone(), cli.url.clone(), git_timeout_ms)); + let bus = Arc::new(Bus::new( + iii.clone(), + cli.url.clone(), + git_timeout_ms.clone(), + )); functions::register_all(&iii, &cfg, &bus); ui::register(&iii); - // Bound last, so the handler closes over fully-built state. - if let Err(e) = configuration::register_config_trigger(&iii, cfg.clone()) { - tracing::warn!(error = %e, "failed to bind the configuration trigger"); - } + // Bound last, so the handler closes over fully-built state. A failure here + // is fatal rather than a warning: the worker would keep serving with limits + // that can never be changed, which is worse than not starting. + configuration::register_config_trigger(&iii, cfg.clone(), git_timeout_ms) + .map_err(|e| anyhow::anyhow!("binding the configuration trigger: {e}"))?; tracing::info!("editor ready, waiting for invocations"); tokio::signal::ctrl_c().await?; diff --git a/editor/src/tree.rs b/editor/src/tree.rs index a0160453d..335df9d1f 100644 --- a/editor/src/tree.rs +++ b/editor/src/tree.rs @@ -17,23 +17,28 @@ use serde_json::Value; /// cannot turn one call into an unbounded allocation. pub fn file_paths(tree: &Value, limit: usize) -> Vec { let mut out = Vec::new(); + // Directories do not grow `out`, so a tree of mostly-empty folders would + // be walked in full however low the file limit is. Bound the visit count + // as well, generously enough that it never truncates a real result. + let mut budget = limit.saturating_mul(8).max(1_000); if let Some(root) = tree.get("root") { - walk(root, "", limit, &mut out); + walk(root, "", limit, &mut budget, &mut out); } out } -fn walk(node: &Value, prefix: &str, limit: usize, out: &mut Vec) { - if out.len() >= limit { +fn walk(node: &Value, prefix: &str, limit: usize, budget: &mut usize, out: &mut Vec) { + if out.len() >= limit || *budget == 0 { return; } let Some(children) = node.get("children").and_then(Value::as_array) else { return; }; for child in children { - if out.len() >= limit { + if out.len() >= limit || *budget == 0 { return; } + *budget -= 1; let Some(name) = child.get("name").and_then(Value::as_str) else { continue; }; @@ -43,7 +48,7 @@ fn walk(node: &Value, prefix: &str, limit: usize, out: &mut Vec) { format!("{prefix}/{name}") }; if is_dir(child) { - walk(child, &path, limit, out); + walk(child, &path, limit, budget, out); } else { // Anything that is not a directory is openable as far as this is // concerned; `editor::open` is the one guard that decides whether @@ -133,6 +138,17 @@ mod tests { assert!(file_paths(&tree, 10).is_empty()); } + /// A tree of empty directories must not be traversed without bound just + /// because it yields no files. + #[test] + fn the_visit_budget_stops_a_directory_only_tree() { + let mut node = json!({ "name": "leaf", "kind": "dir", "children": [] }); + for i in 0..400 { + node = json!({ "name": format!("d{i}"), "kind": "dir", "children": [node] }); + } + assert!(file_paths(&json!({ "root": node }), 10).is_empty()); + } + #[test] fn limit_stops_the_walk() { assert_eq!(file_paths(&sample(), 2).len(), 2); diff --git a/editor/tests/golden/schemas/editor.git.hunks.json b/editor/tests/golden/schemas/editor.git.hunks.json index aff2b5170..daefa4823 100644 --- a/editor/tests/golden/schemas/editor.git.hunks.json +++ b/editor/tests/golden/schemas/editor.git.hunks.json @@ -43,7 +43,7 @@ }, "context_lines": { "default": null, - "description": "Unchanged lines kept around each hunk in `patch`. Defaults to 3, which reads well. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.", + "description": "Unchanged lines kept around each hunk in `patch`. Omitted, it follows the worker's `diff_context_lines` setting. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.", "format": "uint32", "minimum": 0.0, "type": [ diff --git a/editor/tests/golden/schemas/editor.save.json b/editor/tests/golden/schemas/editor.save.json index 433fcc13b..3dc4257a3 100644 --- a/editor/tests/golden/schemas/editor.save.json +++ b/editor/tests/golden/schemas/editor.save.json @@ -10,7 +10,7 @@ }, "expected_mtime": { "default": null, - "description": "The `mtime` from the `editor::open` this edit started from. When it no longer matches the file on disk, the write is refused and the divergence comes back as a patch. Omit only when deliberately overwriting whatever is there.", + "description": "The `mtime` from the `editor::open` this edit started from. When it no longer matches the file on disk, the write is refused and the divergence comes back as a patch. Omit only when deliberately overwriting whatever is there.\n\nResolution is one second, which is what the filesystem reports. Two writes inside the same second are therefore indistinguishable, and the second one wins silently. The guard is built for the case it actually sees, a person and an agent editing minutes apart, not for concurrent writers racing on the same file.", "format": "int64", "type": [ "integer", diff --git a/editor/tests/integration.rs b/editor/tests/integration.rs index 3af8694b5..6d7807d7d 100644 --- a/editor/tests/integration.rs +++ b/editor/tests/integration.rs @@ -70,16 +70,23 @@ async fn boot() -> Option { sleep(Duration::from_millis(800)).await; - let worker = Command::new(env!("CARGO_BIN_EXE_editor")) + // `?` here would drop `iii` without killing it: `Harness` does not exist + // yet, so nothing owns the engine until both children are spawned. + let worker = match Command::new(env!("CARGO_BIN_EXE_editor")) .args(["--url", ENGINE_WS]) - .args([ - "--config", - concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"), - ]) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .ok()?; + { + Ok(child) => child, + Err(e) => { + eprintln!("failed to spawn the worker: {e}"); + let mut iii = iii; + let _ = iii.kill(); + let _ = iii.wait(); + return None; + } + }; sleep(Duration::from_millis(1500)).await; diff --git a/editor/tests/schemas.rs b/editor/tests/schemas.rs index 9e2008461..d9d1c1f36 100644 --- a/editor/tests/schemas.rs +++ b/editor/tests/schemas.rs @@ -1,4 +1,4 @@ -//! Wire-schema snapshots for the six `editor::*` functions. +//! Wire-schema snapshots for every registered `editor::*` function. //! //! `editor::surface::catalog()` is the single source of truth for each //! function's id, registration description, and schemars-derived diff --git a/editor/ui/styles.css b/editor/ui/styles.css index 28da32f80..4d9ea5b04 100644 --- a/editor/ui/styles.css +++ b/editor/ui/styles.css @@ -302,20 +302,22 @@ line-height: 1; } +/* Faded rather than `visibility: hidden`: hiding it removes the button from + the focus order, so a keyboard user could not close an inactive tab. */ [data-iii-ui='editor'] .ed-close { border: 0; background: transparent; - color: var(--color-ink-ghost); + color: transparent; font-size: 13px; line-height: 1; padding: 0 2px; cursor: pointer; - visibility: hidden; } [data-iii-ui='editor'] .ed-tab:hover .ed-close, -[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close { - visibility: visible; +[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close, +[data-iii-ui='editor'] .ed-close:focus-visible { + color: var(--color-ink-ghost); } [data-iii-ui='editor'] .ed-close:hover { From 3752a5afffd7591774e0317e6663cff343ec3f0b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 10:44:58 +0100 Subject: [PATCH 03/11] (MOT-4274) fix(editor): keep gutter ranges exact and cover the truncate editor::git::hunks defaulted to the configured diff_context_lines after the last round of review fixes, which widened every reported range by that many lines. `Hunk` is documented as the ranges a gutter paints, so the default is back to zero and a caller wanting a readable patch asks for context explicitly. The console page does exactly that. The char-boundary truncate had no test. It has three now, including a string of nothing but multibyte characters swept across every cap. The integration test relied on a fixed sleep for worker readiness. Since the worker moved to the configuration worker it registers its functions only after a register and a fetch, both of which retry with backoff on a cold engine, so the sleep was no longer long enough and the test failed the first time it actually ran rather than skipping. It now polls until the function answers. --- editor/src/functions/mod.rs | 72 ++++++++++++++----- editor/src/functions/types.rs | 8 +-- .../golden/schemas/editor.git.hunks.json | 2 +- editor/tests/integration.rs | 64 ++++++++++++----- editor/ui/src/lib/api.ts | 5 +- 5 files changed, 108 insertions(+), 43 deletions(-) diff --git a/editor/src/functions/mod.rs b/editor/src/functions/mod.rs index 468e374c0..5814302aa 100644 --- a/editor/src/functions/mod.rs +++ b/editor/src/functions/mod.rs @@ -651,16 +651,12 @@ fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { Some(dir) => dir, None => active_root(&bus).await, }; - // Context is a knob rather than a constant: a gutter wants - // `-U0` ranges, a person reading the patch wants surrounding - // lines. Defaulting to 3 favours the reader, because the - // ranges are still correct — just wider. - let context = format!( - "-U{}", - req.context_lines - .map(|c| c as usize) - .unwrap_or(cfg.diff_context_lines) - ); + // Defaults to zero, and that is load-bearing: `Hunk` is + // documented as the ranges a gutter paints, and any context at + // all widens them past the lines that actually changed. A + // caller who wants a readable patch asks for context + // explicitly and accepts the wider ranges that come with it. + let context = format!("-U{}", req.context_lines.unwrap_or(0)); let mut args = vec!["diff", &context, "--no-color"]; match req.against { Against::Worktree => {} @@ -695,14 +691,7 @@ fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { // byte cap lands mid-codepoint the moment a diff contains any // multibyte character. Cut back to the last boundary at or // before the cap. - let mut patch = out.stdout; - if patch.len() > cfg.max_diff_bytes { - let mut cut = cfg.max_diff_bytes; - while cut > 0 && !patch.is_char_boundary(cut) { - cut -= 1; - } - patch.truncate(cut); - } + let patch = truncate_on_boundary(out.stdout, cfg.max_diff_bytes); Ok::<_, Error>(GitHunksOutput { path: req.path, against: req.against, @@ -936,6 +925,24 @@ fn relative_to(path: &str, root: &str) -> String { .to_string() } +/// Cut a string to at most `max_bytes`, never mid-codepoint. +/// +/// `String::truncate` panics on a non-boundary index, and a byte cap lands +/// inside a multibyte character the moment a diff contains one. Cutting back to +/// the previous boundary loses at most three bytes of an already-truncated +/// patch, which is the right trade against a panicking handler. +fn truncate_on_boundary(mut text: String, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text; + } + let mut cut = max_bytes; + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + text +} + /// Ahead/behind straight from git, for the sync response. async fn ahead_behind(bus: &Bus, cwd: &str) -> (u32, u32) { let Ok(out) = bus @@ -1171,6 +1178,35 @@ mod parity_tests { ); } + /// The case that used to panic: a cap landing inside a multibyte char. + #[test] + fn truncate_never_splits_a_codepoint() { + // "é" is two bytes, so a cap of 2 lands inside the second one. + let text = "aé".to_string(); + assert_eq!(text.len(), 3); + assert_eq!(truncate_on_boundary(text.clone(), 2), "a"); + // A cap on a boundary keeps everything up to it. + assert_eq!(truncate_on_boundary(text.clone(), 3), "aé"); + // Under the cap is returned untouched. + assert_eq!(truncate_on_boundary(text, 99), "aé"); + } + + #[test] + fn truncate_handles_a_cap_of_zero() { + assert_eq!(truncate_on_boundary("é".to_string(), 0), ""); + } + + /// A patch of nothing but multibyte characters must still cut cleanly. + #[test] + fn truncate_survives_an_all_multibyte_string() { + let text = "→→→→".to_string(); + for cap in 0..=text.len() { + let cut = truncate_on_boundary(text.clone(), cap); + assert!(cut.len() <= cap, "cap {cap} overshot"); + assert!(text.starts_with(&cut), "cap {cap} produced a non-prefix"); + } + } + #[test] fn summarize_keeps_stderr_when_stdout_is_empty() { let out = crate::bus::ExecOutcome { diff --git a/editor/src/functions/types.rs b/editor/src/functions/types.rs index f5551e84d..c2200920d 100644 --- a/editor/src/functions/types.rs +++ b/editor/src/functions/types.rs @@ -364,10 +364,10 @@ pub struct GitHunksInput { /// Which comparison to make. Defaults to `worktree`. #[serde(default)] pub against: Against, - /// Unchanged lines kept around each hunk in `patch`. Omitted, it follows - /// the worker's `diff_context_lines` setting. Pass 0 for ranges that match - /// a gutter exactly — with context, a hunk's reported range widens to - /// include it. + /// Unchanged lines kept around each hunk in `patch`. Defaults to 0, which + /// keeps `hunks` exactly the lines that changed — the ranges a gutter + /// paints. Pass 3 or so for a patch a person will read, and note that the + /// reported ranges widen to include the context you asked for. #[serde(default)] pub context_lines: Option, } diff --git a/editor/tests/golden/schemas/editor.git.hunks.json b/editor/tests/golden/schemas/editor.git.hunks.json index daefa4823..183fe50f9 100644 --- a/editor/tests/golden/schemas/editor.git.hunks.json +++ b/editor/tests/golden/schemas/editor.git.hunks.json @@ -43,7 +43,7 @@ }, "context_lines": { "default": null, - "description": "Unchanged lines kept around each hunk in `patch`. Omitted, it follows the worker's `diff_context_lines` setting. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.", + "description": "Unchanged lines kept around each hunk in `patch`. Defaults to 0, which keeps `hunks` exactly the lines that changed — the ranges a gutter paints. Pass 3 or so for a patch a person will read, and note that the reported ranges widen to include the context you asked for.", "format": "uint32", "minimum": 0.0, "type": [ diff --git a/editor/tests/integration.rs b/editor/tests/integration.rs index 6d7807d7d..236ffbd6c 100644 --- a/editor/tests/integration.rs +++ b/editor/tests/integration.rs @@ -88,11 +88,47 @@ async fn boot() -> Option { } }; - sleep(Duration::from_millis(1500)).await; - Some(Harness { iii, worker }) } +/// Call `editor::diff` until it resolves or `budget` runs out. +/// +/// A `function_not_found` means the worker has not finished booting, which is +/// worth retrying; anything else is a real failure and is returned at once. +async fn await_function( + client: &iii_sdk::IIIClient, + budget: Duration, +) -> Option { + let deadline = std::time::Instant::now() + budget; + let mut last: Option = None; + while std::time::Instant::now() < deadline { + let call = client.trigger(TriggerRequest { + function_id: "editor::diff".into(), + payload: json!({ + "before": "a\nb\nc\n", + "after": "a\nB\nc\n", + "path": "sample.txt", + }), + action: None, + timeout_ms: Some(5_000), + }); + match timeout(Duration::from_secs(10), call).await { + Ok(Ok(value)) => return Some(value), + Ok(Err(e)) => { + let text = e.to_string(); + if !text.contains("not found") { + panic!("editor::diff failed: {text}"); + } + last = Some(text); + } + Err(_) => last = Some("trigger timed out".to_string()), + } + sleep(Duration::from_millis(250)).await; + } + eprintln!("gave up waiting for editor::diff: {last:?}"); + None +} + #[tokio::test] async fn diff_round_trips_over_the_bus() { let Some(_h) = boot().await else { @@ -101,24 +137,14 @@ async fn diff_round_trips_over_the_bus() { }; let client = register_worker(ENGINE_WS, InitOptions::default()); - sleep(Duration::from_millis(500)).await; - let result = timeout( - Duration::from_secs(10), - client.trigger(TriggerRequest { - function_id: "editor::diff".into(), - payload: json!({ - "before": "a\nb\nc\n", - "after": "a\nB\nc\n", - "path": "sample.txt", - }), - action: None, - timeout_ms: Some(5_000), - }), - ) - .await - .expect("trigger timed out") - .expect("trigger failed"); + // Poll rather than sleep for a fixed interval. The worker registers its + // functions only after `configuration::register` and `fetch_config` have + // both come back, and those retry with backoff on a cold engine, so any + // constant chosen here would be either flaky or needlessly slow. + let result = await_function(&client, Duration::from_secs(30)) + .await + .expect("editor::diff never became callable"); assert_eq!(result["identical"], false); assert_eq!(result["added"], 1); diff --git a/editor/ui/src/lib/api.ts b/editor/ui/src/lib/api.ts index b755f11d5..e50c65c46 100644 --- a/editor/ui/src/lib/api.ts +++ b/editor/ui/src/lib/api.ts @@ -174,7 +174,10 @@ export function createApi(host: Host) { removed: number untracked: boolean patch: string - }>('editor::git::hunks', { path, against: 'head' }), + // Context is requested explicitly: the worker defaults to zero so + // that `hunks` stays gutter-accurate, but this view renders the patch + // for a person to read. + }>('editor::git::hunks', { path, against: 'head', context_lines: 3 }), show: (path: string) => call<{ path: string; rev: string; content: string; exists: boolean }>('editor::git::show', { path, From 501db396ebcd3ee22378c4c5b5139329e02eab91 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 11:06:24 +0100 Subject: [PATCH 04/11] (MOT-4274) feat(editor): push change events and diff against upstream Replaces the page's polling with the platform's push primitives, and adds the remote comparison that was missing. The page ran a three-second interval asking for git status and re-reading every open tab. That was wrong on every axis: a git invocation per tick whether or not anything had happened, blind to changes outside a git repository, and reporting the settled state rather than the edit. Two bindings replace it, both garbage-collected with the tab. The `state` trigger type covers the workspace record, which already lived in `state` under scope `editor`, so every buffer open, close, save and folder toggle was already emitting an event the page ignored. A new `editor::changed` trigger type covers file changes, fanned out to subscribers in the shape the github worker uses for `github::called`. The observer is what makes those events real. The workspace only ever reflected files routed through editor::open, and agents do not do that: they call coder::update-file and shell::fs::write, because that is what their prompts point at. So the editor was blind to exactly the edits it exists to show. It now binds harness::hook::post-trigger over shell::* and coder::*, maps the write verbs to a touched path, and emits. Reads are not changes and are filtered out; shell::exec is deliberately not guessed at, because a command can write anything and its argv does not say what, so inferring would produce phantom events. Those still surface through git status. Two things fall out of the hook payload. metadata.fs_scope.root is the session's own workspace, so the editor follows the agent instead of needing a root set by hand. call.function_id names the cause, so a surface can say what did it. The hook is fail-open and always continues: a viewer must never be able to hold or deny a write. editor::git::hunks gains against: upstream, diffing the working tree against @{upstream} for everything not yet pushed. git's own name for it, so it works on any branch without the caller knowing the remote. --- editor/Cargo.lock | 1 + editor/Cargo.toml | 2 + editor/src/events.rs | 259 +++++++++++++ editor/src/functions/mod.rs | 4 + editor/src/functions/types.rs | 5 + editor/src/lib.rs | 2 + editor/src/main.rs | 10 +- editor/src/observe.rs | 352 ++++++++++++++++++ .../golden/schemas/editor.git.hunks.json | 14 + editor/ui/src/lib/events.ts | 110 ++++++ editor/ui/src/page/index.tsx | 44 ++- editor/ui/styles.css | 10 + 12 files changed, 805 insertions(+), 8 deletions(-) create mode 100644 editor/src/events.rs create mode 100644 editor/src/observe.rs create mode 100644 editor/ui/src/lib/events.ts diff --git a/editor/Cargo.lock b/editor/Cargo.lock index f661a7b05..f07eb85f1 100644 --- a/editor/Cargo.lock +++ b/editor/Cargo.lock @@ -278,6 +278,7 @@ name = "editor" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", "iii-console-ui", "iii-sdk", diff --git a/editor/Cargo.toml b/editor/Cargo.toml index a577fad13..80dbd4b45 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -28,6 +28,8 @@ anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } clap = { version = "4", features = ["derive", "env"] } +# Custom trigger types: the SDK's TriggerHandler trait is async. +async-trait = "0.1" # Myers line diff. `editor::diff` is a pure function over two strings, so the # only thing this pulls in is the algorithm itself. similar = "2" diff --git a/editor/src/events.rs b/editor/src/events.rs new file mode 100644 index 000000000..0ed2d924c --- /dev/null +++ b/editor/src/events.rs @@ -0,0 +1,259 @@ +//! `editor::changed` — the push channel that replaced polling. +//! +//! The page used to ask `editor::git::status` every three seconds. That was +//! wrong on every axis: it cost a git invocation per tick whether or not +//! anything had happened, it could not see a change made outside a git +//! repository, and it reported the settled state rather than the edit. The +//! platform already has push primitives, so this worker registers a trigger +//! type and fans out to whoever subscribed, exactly as the `github` worker +//! does for `github::called`. +//! +//! Subscribers are whatever bound the trigger — normally the console page, one +//! binding per open tab, GC'd when the tab closes. Emission is best-effort: a +//! slow or absent subscriber must never delay or fail the write that produced +//! the event. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::trigger::{TriggerConfig, TriggerHandler}; +use iii_sdk::{IIIClient, RegisterTriggerType, TriggerAction}; +use schemars::JsonSchema; +use serde::Serialize; + +/// The trigger type a surface binds to watch the workspace change. +pub const CHANGED: &str = "editor::changed"; + +/// Previews are bounded: an event is a notification, not a file transfer. A +/// subscriber that wants the whole patch asks `editor::git::hunks` for it. +const MAX_PATCH_BYTES: usize = 16 * 1024; + +/// What changed, and enough about it to render a row without a round trip. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ChangedEvent { + /// Path relative to the workspace root. + pub path: String, + /// The function that caused it, e.g. `shell::fs::write`, so a surface can + /// say who did what. + pub cause: String, + /// `created`, `modified`, `deleted`, or `unknown` when the cause does not + /// say. + pub kind: String, + /// Lines added and removed against the file's previous content, when it + /// could be computed. + pub added: u32, + pub removed: u32, + /// Unified patch, truncated to `MAX_PATCH_BYTES`. Empty when there was no + /// previous content to compare against. + pub patch: String, + /// True when `patch` was cut short. + pub truncated: bool, + /// Workspace root the path is relative to, so a surface that follows the + /// agent can notice the root moved. + pub root: String, +} + +type Subscribers = Arc>>; + +/// Bound trigger ids to the function they want invoked. +#[derive(Clone, Default)] +pub struct SubscriberSet { + inner: Subscribers, +} + +impl SubscriberSet { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.inner.lock().map(|s| s.is_empty()).unwrap_or(true) + } + + pub fn function_ids(&self) -> Vec { + self.inner + .lock() + .map(|s| s.values().cloned().collect()) + .unwrap_or_default() + } + + fn add(&self, trigger_id: String, function_id: String) { + if let Ok(mut s) = self.inner.lock() { + s.insert(trigger_id, function_id); + } + } + + fn remove(&self, trigger_id: &str) { + if let Ok(mut s) = self.inner.lock() { + s.remove(trigger_id); + } + } +} + +struct ChangedTriggerHandler { + subscribers: SubscriberSet, +} + +#[async_trait] +impl TriggerHandler for ChangedTriggerHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + tracing::info!( + trigger_type = CHANGED, + id = %config.id, + function_id = %config.function_id, + "change subscription registered" + ); + self.subscribers.add(config.id, config.function_id); + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + tracing::info!(trigger_type = CHANGED, id = %config.id, "change subscription unregistered"); + self.subscribers.remove(&config.id); + Ok(()) + } +} + +/// Fans `editor::changed` out to every current subscriber. +#[derive(Clone)] +pub struct ChangedEmitter { + iii: Arc, + subscribers: SubscriberSet, +} + +impl ChangedEmitter { + pub fn new(iii: Arc, subscribers: SubscriberSet) -> Self { + Self { iii, subscribers } + } + + pub fn has_subscribers(&self) -> bool { + !self.subscribers.is_empty() + } + + /// Fire and forget. Delivery failures are logged and swallowed: the write + /// that produced this event has already happened, and failing it because a + /// browser tab went away would be absurd. + pub async fn emit(&self, mut event: ChangedEvent) { + let targets = self.subscribers.function_ids(); + if targets.is_empty() { + return; + } + if event.patch.len() > MAX_PATCH_BYTES { + let mut cut = MAX_PATCH_BYTES; + while cut > 0 && !event.patch.is_char_boundary(cut) { + cut -= 1; + } + event.patch.truncate(cut); + event.truncated = true; + } + let payload = match serde_json::to_value(&event) { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "editor::changed payload failed to serialize"); + return; + } + }; + for function_id in targets { + if let Err(e) = self + .iii + .trigger(TriggerRequest { + function_id: function_id.clone(), + payload: payload.clone(), + action: Some(TriggerAction::Void), + timeout_ms: None, + }) + .await + { + tracing::warn!(function_id = %function_id, error = %e, "editor::changed fan-out failed"); + } + } + } +} + +/// Register the trigger type and return the emitter wired to its subscriber +/// set. Call before registering functions so the emitter can be threaded in. +pub fn register_changed_trigger(iii: &Arc) -> ChangedEmitter { + let subscribers = SubscriberSet::new(); + let _ = iii.register_trigger_type(RegisterTriggerType::new( + CHANGED, + "Fires when a file in the workspace changes, whoever changed it — \ + including an agent that never called this worker.", + ChangedTriggerHandler { + subscribers: subscribers.clone(), + }, + )); + tracing::info!(trigger_type = CHANGED, "registered trigger type"); + ChangedEmitter::new(iii.clone(), subscribers) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event() -> ChangedEvent { + ChangedEvent { + path: "a.rs".into(), + cause: "shell::fs::write".into(), + kind: "modified".into(), + added: 1, + removed: 0, + patch: String::new(), + truncated: false, + root: "/repo".into(), + } + } + + #[test] + fn an_empty_set_reports_empty() { + assert!(SubscriberSet::new().is_empty()); + } + + #[test] + fn register_then_unregister_tracks_the_binding() { + let s = SubscriberSet::new(); + s.add("t1".into(), "iii::editor-ui::events::abc".into()); + assert!(!s.is_empty()); + assert_eq!(s.function_ids(), vec!["iii::editor-ui::events::abc"]); + s.remove("t1"); + assert!(s.is_empty()); + } + + /// Two tabs bind separately and must both be delivered to. + #[test] + fn distinct_triggers_are_distinct_subscribers() { + let s = SubscriberSet::new(); + s.add("t1".into(), "fn::a".into()); + s.add("t2".into(), "fn::b".into()); + let mut ids = s.function_ids(); + ids.sort(); + assert_eq!(ids, vec!["fn::a", "fn::b"]); + } + + #[test] + fn re_registering_the_same_trigger_does_not_duplicate() { + let s = SubscriberSet::new(); + s.add("t1".into(), "fn::a".into()); + s.add("t1".into(), "fn::a".into()); + assert_eq!(s.function_ids().len(), 1); + } + + #[test] + fn the_event_serializes_with_every_field() { + let v = serde_json::to_value(event()).unwrap(); + for key in [ + "path", + "cause", + "kind", + "added", + "removed", + "patch", + "truncated", + "root", + ] { + assert!(v.get(key).is_some(), "{key} missing from the wire event"); + } + } +} diff --git a/editor/src/functions/mod.rs b/editor/src/functions/mod.rs index 5814302aa..50d5d2239 100644 --- a/editor/src/functions/mod.rs +++ b/editor/src/functions/mod.rs @@ -662,6 +662,10 @@ fn register_git_hunks(iii: &Arc, cfg: &ConfigCell, bus: &Arc) { Against::Worktree => {} Against::Index => args.push("--cached"), Against::Head => args.push("HEAD"), + // `@{upstream}` is git's own name for it, so this works on + // any branch without the caller knowing the remote or the + // branch name. + Against::Upstream => args.push("@{upstream}"), } args.push("--"); args.push(&req.path); diff --git a/editor/src/functions/types.rs b/editor/src/functions/types.rs index c2200920d..af9a9b611 100644 --- a/editor/src/functions/types.rs +++ b/editor/src/functions/types.rs @@ -342,6 +342,10 @@ pub enum Against { Index, /// Everything since the last commit: working tree vs HEAD. Head, + /// Everything not yet pushed: working tree vs the branch's upstream + /// (`@{upstream}`). Fails when the branch has no upstream configured, + /// which is a real answer rather than an error to swallow. + Upstream, } impl Against { @@ -350,6 +354,7 @@ impl Against { Against::Worktree => "worktree", Against::Index => "index", Against::Head => "head", + Against::Upstream => "upstream", } } } diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 6d65873e7..6e50f2eba 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -10,11 +10,13 @@ pub mod bus; pub mod config; pub mod configuration; pub mod diff; +pub mod events; pub mod functions; pub mod fuzzy; pub mod git; pub mod lang; pub mod manifest; +pub mod observe; pub mod surface; pub mod tree; pub mod ui; diff --git a/editor/src/main.rs b/editor/src/main.rs index 864d054db..e9126f7f6 100644 --- a/editor/src/main.rs +++ b/editor/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use editor::{bus::Bus, config, configuration, functions, manifest, ui}; +use editor::{bus::Bus, config, configuration, events, functions, manifest, observe, ui}; use iii_sdk::runtime::WorkerMetadata; use iii_sdk::{register_worker, InitOptions}; use std::sync::Arc; @@ -97,9 +97,17 @@ async fn main() -> Result<()> { git_timeout_ms.clone(), )); + // Custom trigger types go up before the functions that emit on them, so a + // handler can never fire against a half-built subscriber set. + let changed = events::register_changed_trigger(&iii); + functions::register_all(&iii, &cfg, &bus); ui::register(&iii); + // The observer is what makes the workspace see edits made by anything, not + // just by callers of this worker. + observe::bind(&iii, &cfg, &bus, changed); + // Bound last, so the handler closes over fully-built state. A failure here // is fatal rather than a warning: the worker would keep serving with limits // that can never be changed, which is worse than not starting. diff --git a/editor/src/observe.rs b/editor/src/observe.rs new file mode 100644 index 000000000..f7c3c6f7a --- /dev/null +++ b/editor/src/observe.rs @@ -0,0 +1,352 @@ +//! Watching what the agent does, without the agent cooperating. +//! +//! The workspace only reflected files someone deliberately routed through +//! `editor::open`. Agents do not: they call `coder::update-file` and +//! `shell::fs::write`, because that is what their prompts and skills point at. +//! So the editor was blind to exactly the edits it exists to show. +//! +//! The fix is to observe rather than require cooperation. Every call already +//! crosses the bus, and the harness fans its calls out to bound hooks, so this +//! binds `harness::hook::post-trigger` on the filesystem-touching functions and +//! turns each one into an `editor::changed` event. `post-trigger` rather than +//! `pre-`: the write has to have happened before there is anything to report. +//! +//! Two things fall out of the hook payload for free. `metadata.fs_scope.root` +//! is the session's own workspace, so the editor can follow the agent instead +//! of needing a root set by hand. And `call.function_id` names the cause, so a +//! surface can say what did it. +//! +//! The hook is **fail-open and advisory**: it returns `Continue` unconditionally +//! and never inspects the result for a decision. Holding or denying a write +//! because a viewer was slow would be indefensible. + +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::{IIIClient, RegisterFunction}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::bus::Bus; +use crate::config::WorkerConfig; +use crate::configuration::ConfigCell; +use crate::events::{ChangedEmitter, ChangedEvent}; +use crate::{diff, lang, workspace}; + +pub const HOOK_FN_ID: &str = "editor::on-file-change"; +/// The two families that touch files. Matching broadly and filtering on the +/// verb keeps this from breaking when either worker grows a new write path. +const HOOK_FUNCTIONS: &[&str] = &["shell::*", "coder::*"]; +const HOOK_TIMEOUT_MS: u64 = 3_000; +/// A viewer must never be able to block a write. +const HOOK_ON_ERROR: &str = "fail_open"; + +/// The subset of the harness hook payload this worker reads. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct HookInput { + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub call: Option, +} + +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct HookCall { + pub function_id: String, + #[serde(default)] + pub arguments: Value, +} + +/// Always `continue`. This hook observes; it never decides. +#[derive(Debug, Serialize, JsonSchema)] +pub struct HookOutput { + pub decision: &'static str, +} + +impl Default for HookOutput { + fn default() -> Self { + Self { + decision: "continue", + } + } +} + +/// What a watched function did to which path. +#[derive(Debug, PartialEq, Eq)] +pub struct Touch { + pub path: String, + pub kind: &'static str, +} + +/// Read the touched path out of a call, or `None` when the call did not write. +/// +/// Deliberately a whitelist of verbs rather than "anything under shell": most +/// of that namespace reads, and reporting a read as a change would make the +/// feed useless. `shell::exec` is excluded on purpose — a command can write +/// anything and its argv does not say what, so guessing would produce phantom +/// events. Those changes still surface through git status. +pub fn touched(call: &HookCall) -> Option { + let kind = match call.function_id.as_str() { + "shell::fs::write" | "coder::update-file" => "modified", + "coder::create-file" => "created", + "shell::fs::rm" | "coder::delete-file" => "deleted", + "shell::fs::sed" => "modified", + "shell::fs::mv" | "coder::move" => "moved", + _ => return None, + }; + + // `path` covers most; `mv` uses `dst`; the batch shapes carry `files`. + let args = &call.arguments; + let path = args + .get("dst") + .and_then(Value::as_str) + .or_else(|| args.get("path").and_then(Value::as_str)) + .map(str::to_string) + .or_else(|| { + args.get("files") + .and_then(Value::as_array) + .and_then(|f| f.first()) + .and_then(|f| f.get("path").or_else(|| f.get("dst"))) + .and_then(Value::as_str) + .map(str::to_string) + })?; + + Some(Touch { path, kind }) +} + +/// The session's workspace root, when the harness stamped one. +pub fn session_root(metadata: Option<&Value>) -> Option { + metadata? + .get("fs_scope")? + .get("root")? + .as_str() + .map(str::to_string) +} + +/// Make `path` relative to `root`, leaving anything outside it alone. +pub fn relative(path: &str, root: &str) -> String { + if root == "." || root.is_empty() { + return path.to_string(); + } + let trimmed = root.trim_end_matches('/'); + path.strip_prefix(trimmed) + .map(|rest| rest.trim_start_matches('/')) + .filter(|rest| !rest.is_empty()) + .unwrap_or(path) + .to_string() +} + +/// Bind the observer. A failed bind is logged, not fatal: the harness may not +/// be installed, and an editor without a live feed is still an editor. +pub fn bind(iii: &Arc, cfg: &ConfigCell, bus: &Arc, emitter: ChangedEmitter) { + let cfg = cfg.clone(); + let bus = bus.clone(); + iii.register_function( + HOOK_FN_ID, + RegisterFunction::new_async(move |input: HookInput| { + let cfg = cfg.clone(); + let bus = bus.clone(); + let emitter = emitter.clone(); + async move { + // Nobody watching means nothing to compute. + if emitter.has_subscribers() { + let snapshot = cfg.read().await.clone(); + report(&bus, &snapshot, &emitter, input).await; + } + Ok::(HookOutput::default()) + } + }) + .description( + "Internal: turns a filesystem call made by anything into an editor::changed \ + event. Observes only — always continues.", + ) + .metadata(json!({ "internal": true, "trace_hidden": true })), + ); + + match iii.register_trigger(RegisterTriggerInput { + trigger_type: "harness::hook::post-trigger".to_string(), + function_id: HOOK_FN_ID.to_string(), + config: json!({ + "functions": HOOK_FUNCTIONS, + "timeout_ms": HOOK_TIMEOUT_MS, + "on_error": HOOK_ON_ERROR, + }), + metadata: None, + }) { + Ok(_) => tracing::info!(function_id = HOOK_FN_ID, "file-change observer bound"), + Err(e) => tracing::warn!( + error = %e, + "failed to bind the file-change observer; the workspace will not see \ + edits made outside this worker" + ), + } +} + +/// Build and emit the event for one observed call. +async fn report(bus: &Bus, cfg: &WorkerConfig, emitter: &ChangedEmitter, input: HookInput) { + let Some(call) = input.call else { return }; + let Some(touch) = touched(&call) else { return }; + + // Prefer the session's own workspace: it is where the agent is actually + // working, which is not necessarily where the editor was last pointed. + let root = match session_root(input.metadata.as_ref()) { + Some(root) => root, + None => bus + .state_get(workspace::ACTIVE_ROOT_KEY) + .await + .ok() + .flatten() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| ".".to_string()), + }; + let rel = relative(&touch.path, &root); + + // A deleted file has nothing to read; everything else gets a patch against + // HEAD when the folder is a repo, and none when it is not. Either way the + // event goes out — the notification matters more than the preview. + let (added, removed, patch) = if touch.kind == "deleted" { + (0, 0, String::new()) + } else { + match bus + .git(&["diff", "-U3", "--no-color", "--", &rel], Some(&root)) + .await + { + Ok(out) if out.exit_code == Some(0) && !out.stdout.trim().is_empty() => { + let hunks = crate::git::parse_hunk_headers(&out.stdout); + ( + hunks.iter().map(|h| h.added).sum(), + hunks.iter().map(|h| h.removed).sum(), + out.stdout, + ) + } + // Not a repo, or a brand-new file git cannot diff: fall back to + // counting the file as added so the row still says something. + _ => match bus.read(&rel, cfg.max_file_bytes).await { + Ok(file) => { + let d = diff::diff( + "", + &file.content, + Some(&rel), + cfg.diff_context_lines, + cfg.max_diff_bytes, + ); + (d.added, d.removed, d.patch) + } + Err(_) => (0, 0, String::new()), + }, + } + }; + + let _ = lang::for_path(&rel); + emitter + .emit(ChangedEvent { + path: rel, + cause: call.function_id, + kind: touch.kind.to_string(), + added, + removed, + patch, + truncated: false, + root, + }) + .await; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call(id: &str, args: Value) -> HookCall { + HookCall { + function_id: id.to_string(), + arguments: args, + } + } + + #[test] + fn a_write_is_reported_as_modified() { + let t = touched(&call("shell::fs::write", json!({ "path": "a.rs" }))).unwrap(); + assert_eq!( + t, + Touch { + path: "a.rs".into(), + kind: "modified" + } + ); + } + + #[test] + fn a_create_is_reported_as_created() { + let t = touched(&call("coder::create-file", json!({ "path": "new.rs" }))).unwrap(); + assert_eq!(t.kind, "created"); + } + + #[test] + fn a_move_reports_its_destination() { + let t = touched(&call("shell::fs::mv", json!({ "src": "a", "dst": "b" }))).unwrap(); + assert_eq!(t.path, "b", "the new location is what a surface shows"); + assert_eq!(t.kind, "moved"); + } + + #[test] + fn a_batch_shape_reports_its_first_path() { + let t = touched(&call( + "coder::create-file", + json!({ "files": [{ "path": "one.rs" }, { "path": "two.rs" }] }), + )) + .unwrap(); + assert_eq!(t.path, "one.rs"); + } + + /// Reads must not appear in a change feed. + #[test] + fn reads_are_not_changes() { + assert!(touched(&call("shell::fs::read", json!({ "path": "a" }))).is_none()); + assert!(touched(&call("coder::read-file", json!({ "path": "a" }))).is_none()); + assert!(touched(&call("shell::fs::ls", json!({ "path": "." }))).is_none()); + assert!(touched(&call("coder::tree", json!({ "path": "." }))).is_none()); + } + + /// `shell::exec` can write anything and its argv does not say what, so a + /// guess would produce phantom events. + #[test] + fn exec_is_not_guessed_at() { + assert!(touched(&call("shell::exec", json!({ "command": "rm -rf x" }))).is_none()); + } + + #[test] + fn a_write_without_a_path_is_skipped() { + assert!(touched(&call("shell::fs::write", json!({}))).is_none()); + } + + #[test] + fn the_session_workspace_is_read_from_the_stamp() { + let md = json!({ "fs_scope": { "root": "/srv/app" } }); + assert_eq!(session_root(Some(&md)).as_deref(), Some("/srv/app")); + assert!(session_root(None).is_none()); + assert!(session_root(Some(&json!({}))).is_none()); + } + + #[test] + fn paths_are_made_relative_to_the_root() { + assert_eq!(relative("/srv/app/src/a.rs", "/srv/app"), "src/a.rs"); + assert_eq!(relative("/srv/app/src/a.rs", "/srv/app/"), "src/a.rs"); + } + + #[test] + fn a_path_outside_the_root_is_left_absolute() { + assert_eq!(relative("/elsewhere/a.rs", "/srv/app"), "/elsewhere/a.rs"); + } + + #[test] + fn a_dot_root_leaves_the_path_alone() { + assert_eq!(relative("src/a.rs", "."), "src/a.rs"); + } + + #[test] + fn the_hook_always_continues() { + assert_eq!(HookOutput::default().decision, "continue"); + } +} diff --git a/editor/tests/golden/schemas/editor.git.hunks.json b/editor/tests/golden/schemas/editor.git.hunks.json index 183fe50f9..e17c51e15 100644 --- a/editor/tests/golden/schemas/editor.git.hunks.json +++ b/editor/tests/golden/schemas/editor.git.hunks.json @@ -27,6 +27,13 @@ "head" ], "type": "string" + }, + { + "description": "Everything not yet pushed: working tree vs the branch's upstream (`@{upstream}`). Fails when the branch has no upstream configured, which is a real answer rather than an error to swallow.", + "enum": [ + "upstream" + ], + "type": "string" } ] } @@ -96,6 +103,13 @@ "head" ], "type": "string" + }, + { + "description": "Everything not yet pushed: working tree vs the branch's upstream (`@{upstream}`). Fails when the branch has no upstream configured, which is a real answer rather than an error to swallow.", + "enum": [ + "upstream" + ], + "type": "string" } ] }, diff --git a/editor/ui/src/lib/events.ts b/editor/ui/src/lib/events.ts new file mode 100644 index 000000000..a60927589 --- /dev/null +++ b/editor/ui/src/lib/events.ts @@ -0,0 +1,110 @@ +/** + * Push subscriptions, replacing the page's polling. + * + * The page used to run a three-second `setInterval` asking for git status and + * re-reading every open tab. That was a mistake: the platform pushes. Two + * bindings replace it, and both are garbage-collected with the tab. + * + * - The **`state`** trigger type fires on any key change in a scope. The + * workspace record already lives in `state` under scope `editor`, so every + * buffer open, close, save and folder toggle was already emitting an event + * the page was ignoring. + * - **`editor::changed`** is the worker's own trigger type, fired when a file + * changes however it changed — including by an agent that never called this + * worker. + * + * The `iii::` handler prefix is deliberate: it keeps the per-event invocations + * span-suppressed and out of the trace feed, which matters when an agent is + * writing files in a loop. + */ + +import type { Host } from '@iii-dev/console-ui' +import { useCallback, useEffect, useRef } from 'react' + +const STATE_EVENTS_FN = 'iii::editor-ui::state' +const CHANGED_EVENTS_FN = 'iii::editor-ui::changed' + +export interface StateEvent { + type: 'state' + event_type: 'state:created' | 'state:updated' | 'state:deleted' + scope: string + key: string + old_value: unknown + new_value: unknown +} + +export interface ChangedEvent { + path: string + cause: string + kind: 'created' | 'modified' | 'deleted' | 'moved' | string + added: number + removed: number + patch: string + truncated: boolean + root: string +} + +type Listener = (event: T) => void +export type Subscribe = (listener: Listener) => () => void + +/** + * Bind both event sources for this component's lifetime and return + * subscribe functions for descendants. + * + * One binding per tab, registered on mount and unregistered on unmount, so a + * hot reload disposes them with the page. + */ +export function useWorkspaceEvents(host: Host): { + onState: Subscribe + onChanged: Subscribe +} { + const stateListeners = useRef>>(new Set()) + const changedListeners = useRef>>(new Set()) + + useEffect(() => { + const offState = host.iii.on(STATE_EVENTS_FN, (event) => { + // Only this worker's scope: the state worker is shared, and another + // worker's keys are none of the editor's business. + if (event?.type !== 'state' || event.scope !== 'editor') return + for (const listener of [...stateListeners.current]) listener(event) + }) + const offChanged = host.iii.on(CHANGED_EVENTS_FN, (event) => { + if (typeof event?.path !== 'string') return + for (const listener of [...changedListeners.current]) listener(event) + }) + + const unbindState = host.iii.registerTrigger({ + type: 'state', + function_id: `${STATE_EVENTS_FN}::${host.iii.browserId}`, + config: { scope: 'editor' }, + }) + const unbindChanged = host.iii.registerTrigger({ + type: 'editor::changed', + function_id: `${CHANGED_EVENTS_FN}::${host.iii.browserId}`, + config: {}, + }) + + return () => { + unbindState() + unbindChanged() + offState() + offChanged() + } + }, [host]) + + const onState = useCallback>((listener) => { + stateListeners.current.add(listener) + return () => { + stateListeners.current.delete(listener) + } + }, []) + + const onChanged = useCallback>((listener) => { + changedListeners.current.add(listener) + return () => { + changedListeners.current.delete(listener) + } + }, []) + + return { onState, onChanged } +} diff --git a/editor/ui/src/page/index.tsx b/editor/ui/src/page/index.tsx index c091742ae..46883718b 100644 --- a/editor/ui/src/page/index.tsx +++ b/editor/ui/src/page/index.tsx @@ -53,8 +53,8 @@ import { type TreeNode, visibleRows, } from '../lib/api' +import { type ChangedEvent, useWorkspaceEvents } from '../lib/events' -const POLL_MS = 3_000 /** How long a row keeps its "just changed" accent after an edit lands. */ const FLASH_MS = 12_000 @@ -100,6 +100,7 @@ export function EditorPage({ host }: { host: Host }) { const [gitNote, setGitNote] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const [lastChange, setLastChange] = useState(null) // Read inside the poll without making it a dependency — rebuilding the // interval on every keystroke would reset the timer and stall the feed. @@ -264,14 +265,38 @@ export function EditorPage({ host }: { host: Host }) { } }, [api]) + const { onState, onChanged } = useWorkspaceEvents(host) + + // The workspace record lives in `state`, so every buffer and expansion change + // arrives as an event. One read on mount seeds the git overlay; after that + // nothing is polled. useEffect(() => { - const id = setInterval(() => { - void refreshGit() - void refreshBuffers() - }, POLL_MS) void refreshGit() - return () => clearInterval(id) - }, [refreshGit, refreshBuffers]) + }, [refreshGit]) + + // A change to this worker's `state` scope means the shared workspace moved: + // an agent opened or closed something, or saved. Re-read it once per event + // rather than on a timer. + useEffect( + () => + onState(() => { + void refreshBuffers() + }), + [onState, refreshBuffers], + ) + + // A file changed, however it changed. The event carries enough to light the + // row up immediately; the git overlay is refreshed for the marks. + useEffect( + () => + onChanged((event: ChangedEvent) => { + setFlashed((prev) => ({ ...prev, [event.path]: Date.now() })) + setLastChange(event) + void refreshBuffers() + void refreshGit() + }), + [onChanged, refreshBuffers, refreshGit], + ) useEffect(() => { if (Object.keys(flashed).length === 0) return @@ -684,6 +709,11 @@ export function EditorPage({ host }: { host: Host }) { ) ))} {dirty ? 'unsaved' : 'saved'} + {lastChange && ( + + {lastChange.kind} {lastChange.path.split('/').pop()} + + )} )} diff --git a/editor/ui/styles.css b/editor/ui/styles.css index 4d9ea5b04..793a93328 100644 --- a/editor/ui/styles.css +++ b/editor/ui/styles.css @@ -596,3 +596,13 @@ [data-iii-ui='editor'] .ed-dline[data-k='meta'] { color: var(--color-ink-ghost); } + +/* The most recent observed edit. Pushed by `editor::changed`, so this updates + as an agent works rather than on a timer. */ +[data-iii-ui='editor'] .ed-live { + color: var(--color-accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 22ch; +} From e76498e82196c3a1c48f238b3b2c5843c3b7cc0b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 11:53:16 +0100 Subject: [PATCH 05/11] (MOT-4274) fix(editor): read observed files by absolute path, and log the observer The observer emitted an event for every write it saw, but the event was empty. `report` computed a root-relative path and then handed that to the read, which resolves through shell's own working directory rather than the session root. For a session rooted anywhere other than shell's cwd the read missed, the error was swallowed by the fallback, and the event went out with added: 0 and no patch. That is indistinguishable from the hook never firing. Reads now go out absolute; only the reported path stays relative to the root, which is what a surface wants to display. The reason this took a rig boot and a harness turn to find is that the emitter logged only failures, so a delivered-but-empty event left no trace at all. Every branch now says what it did: no call payload, not a write, no subscribers, and a successful delivery with its counts. An observer without observability was the actual defect. Verified end to end against a live rig: the hook fires, the event is delivered to the page's subscription carrying the real line counts, and the page renders it. --- editor/Cargo.lock | 5 +++-- editor/Cargo.toml | 3 +++ editor/src/events.rs | 10 +++++++++ editor/src/observe.rs | 48 +++++++++++++++++++++++++++++++++++++------ observer-proof.txt | 1 + 5 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 observer-proof.txt diff --git a/editor/Cargo.lock b/editor/Cargo.lock index f07eb85f1..b0c6c8467 100644 --- a/editor/Cargo.lock +++ b/editor/Cargo.lock @@ -281,6 +281,7 @@ dependencies = [ "async-trait", "clap", "iii-console-ui", + "iii-helpers", "iii-sdk", "schemars", "serde", @@ -674,9 +675,9 @@ dependencies = [ [[package]] name = "iii-helpers" -version = "0.21.8" +version = "0.21.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84bdc7bbc3abfde934a62cdc5d3045adf52914dfc1ed6c20f8af691fc561dc55" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" dependencies = [ "futures-util", "opentelemetry", diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 80dbd4b45..57ed9286f 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -19,6 +19,9 @@ path = "src/lib.rs" # `IIIClient` types. iii-sdk = "=0.21.6" iii-console-ui = { path = "../crates/console-ui" } +# Shared observability: the observer fires inside a harness turn, so its span +# has to hang off that turn rather than dangle as its own trace root. +iii-helpers = "=0.21.6" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/editor/src/events.rs b/editor/src/events.rs index 0ed2d924c..bb9d86874 100644 --- a/editor/src/events.rs +++ b/editor/src/events.rs @@ -139,6 +139,7 @@ impl ChangedEmitter { pub async fn emit(&self, mut event: ChangedEvent) { let targets = self.subscribers.function_ids(); if targets.is_empty() { + tracing::debug!(path = %event.path, "editor::changed: nobody subscribed, dropping"); return; } if event.patch.len() > MAX_PATCH_BYTES { @@ -168,6 +169,15 @@ impl ChangedEmitter { .await { tracing::warn!(function_id = %function_id, error = %e, "editor::changed fan-out failed"); + } else { + tracing::info!( + function_id = %function_id, + path = %event.path, + kind = %event.kind, + added = event.added, + removed = event.removed, + "editor::changed delivered" + ); } } } diff --git a/editor/src/observe.rs b/editor/src/observe.rs index f7c3c6f7a..66ebbce9b 100644 --- a/editor/src/observe.rs +++ b/editor/src/observe.rs @@ -33,7 +33,7 @@ use crate::bus::Bus; use crate::config::WorkerConfig; use crate::configuration::ConfigCell; use crate::events::{ChangedEmitter, ChangedEvent}; -use crate::{diff, lang, workspace}; +use crate::{diff, workspace}; pub const HOOK_FN_ID: &str = "editor::on-file-change"; /// The two families that touch files. Matching broadly and filtering on the @@ -151,9 +151,21 @@ pub fn bind(iii: &Arc, cfg: &ConfigCell, bus: &Arc, emitter: Cha let emitter = emitter.clone(); async move { // Nobody watching means nothing to compute. + if !emitter.has_subscribers() { + tracing::debug!("observer: no subscribers, skipping"); + } if emitter.has_subscribers() { let snapshot = cfg.read().await.clone(); - report(&bus, &snapshot, &emitter, input).await; + // Named span under the caller's trace: this fires inside an + // agent turn, and an observed edit that dangled as its own + // root would be unreadable in the traces view — the whole + // point is seeing the write and the event as one chain. + iii_helpers::observability::run_in_span( + "editor::observe file change", + None, + || report(&bus, &snapshot, &emitter, input), + ) + .await; } Ok::(HookOutput::default()) } @@ -186,8 +198,14 @@ pub fn bind(iii: &Arc, cfg: &ConfigCell, bus: &Arc, emitter: Cha /// Build and emit the event for one observed call. async fn report(bus: &Bus, cfg: &WorkerConfig, emitter: &ChangedEmitter, input: HookInput) { - let Some(call) = input.call else { return }; - let Some(touch) = touched(&call) else { return }; + let Some(call) = input.call else { + tracing::debug!("observer: hook fired with no call payload"); + return; + }; + let Some(touch) = touched(&call) else { + tracing::debug!(function_id = %call.function_id, "observer: not a write, ignoring"); + return; + }; // Prefer the session's own workspace: it is where the agent is actually // working, which is not necessarily where the editor was last pointed. @@ -202,6 +220,25 @@ async fn report(bus: &Bus, cfg: &WorkerConfig, emitter: &ChangedEmitter, input: .unwrap_or_else(|| ".".to_string()), }; let rel = relative(&touch.path, &root); + // `bus.read` resolves through shell, whose jail/working_dir is its own, NOT + // this root. Handing it the root-relative path made every read outside + // shell's cwd fail into an empty patch — silently, because the fallback + // swallows the error. Reads therefore go out absolute; only the reported + // path stays relative. + let absolute = if touch.path.starts_with('/') { + touch.path.clone() + } else if root == "." { + rel.clone() + } else { + format!("{}/{}", root.trim_end_matches('/'), rel) + }; + tracing::debug!( + function_id = %call.function_id, + kind = touch.kind, + path = %rel, + root = %root, + "observer: reporting a change" + ); // A deleted file has nothing to read; everything else gets a patch against // HEAD when the folder is a repo, and none when it is not. Either way the @@ -223,7 +260,7 @@ async fn report(bus: &Bus, cfg: &WorkerConfig, emitter: &ChangedEmitter, input: } // Not a repo, or a brand-new file git cannot diff: fall back to // counting the file as added so the row still says something. - _ => match bus.read(&rel, cfg.max_file_bytes).await { + _ => match bus.read(&absolute, cfg.max_file_bytes).await { Ok(file) => { let d = diff::diff( "", @@ -239,7 +276,6 @@ async fn report(bus: &Bus, cfg: &WorkerConfig, emitter: &ChangedEmitter, input: } }; - let _ = lang::for_path(&rel); emitter .emit(ChangedEvent { path: rel, diff --git a/observer-proof.txt b/observer-proof.txt new file mode 100644 index 000000000..c304e09c7 --- /dev/null +++ b/observer-proof.txt @@ -0,0 +1 @@ +observed by the editor worker From 724349d27938221802b0a6bb1ce8f730d48d2387 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 13:59:18 +0100 Subject: [PATCH 06/11] (MOT-4274) chore(editor): drop a test artifact that reached the branch observer-proof.txt was written by an agent while verifying the file-change observer end to end, and was committed by mistake. It is evidence of a probe, not part of the worker. --- observer-proof.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 observer-proof.txt diff --git a/observer-proof.txt b/observer-proof.txt deleted file mode 100644 index c304e09c7..000000000 --- a/observer-proof.txt +++ /dev/null @@ -1 +0,0 @@ -observed by the editor worker From 67ed7f453847b93fbf549220feece976c60948e5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 13:59:18 +0100 Subject: [PATCH 07/11] (MOT-4274) feat(editor): render code and diffs with pierre The read-only view used the console's shared Monaco editor, which is deliberately chrome-less: lineNumbers off, no minimap, no folding, no glyph margin. Code read as unstyled text and diffs carried no colour, so the page failed at the one job it exists for. Pierre's File now backs a new read view, the default on open, and FileDiff backs every patch. That replaces a hand-rolled renderer which printed diff --git, index, and @@ headers as though they were file content. Monaco stays for the edit view, since pierre is render-only. Patches in the chat function-trigger message render through the same component, at zero marginal bytes. parsePatchFiles is used rather than PatchDiff on purpose: PatchDiff throws unless the text yields exactly one file with one diff, so a clean file and a multi-file patch would each take the page down. Bundling pierre naively costs 10.3 MB, over the console's 8 MiB asset cap. But 71% of that is @shikijs/langs shipping 347 TextMate grammars and 12.5% is 65 themes; pierre itself is 350 KB. Aliasing shiki's root bundle to a 20-language allowlist, emptying the theme catalogs down to pierre-dark and pierre-light, and stubbing the unused oniguruma wasm brings the asset to 1.995 MiB, 25% of the cap. No console change was needed, and no stylesheet: pierre inlines its styles into its own shadow root. Two build guards hold that line, both verified to fail on purpose: a 2.5 MiB budget, and a smoke test that renders a TypeScript snippet and fails the build if it stops tokenising. A 4 MiB budget was measured and rejected, because moving @pierre/theming's private collection path adds 1.35 MB and still passes it, which is precisely the regression the guard exists to catch. --- editor/ui/build.mjs | 397 +++++++++++++++++- editor/ui/package.json | 3 +- .../ui/src/function-trigger-message/index.tsx | 79 ++-- editor/ui/src/page/index.tsx | 188 +++++---- editor/ui/styles.css | 79 +--- pnpm-lock.yaml | 3 + 6 files changed, 581 insertions(+), 168 deletions(-) diff --git a/editor/ui/build.mjs b/editor/ui/build.mjs index ba3ee5242..4a805e0ef 100644 --- a/editor/ui/build.mjs +++ b/editor/ui/build.mjs @@ -9,29 +9,412 @@ * "Invalid hook call" with nothing pointing at the cause, and a bundled editor * would ship megabytes to duplicate the Monaco the console already runs. * `--watch` pairs with the worker's III_EDITOR_UI_WATCH poller. + * + * --------------------------------------------------------------------------- + * Why this file has plugins: narrowing @pierre/diffs to fit the asset cap + * --------------------------------------------------------------------------- + * The page renders files and diffs with `@pierre/diffs`. Bundled as-is that + * costs 10,808,316 bytes — over the console's 8 MiB per-asset cap + * (`console/src/ui_assets.rs`, MAX_ASSET_BYTES). Almost none of that is + * pierre: its own code is 350 KB. The 10 MB is shiki's *full* bundle, which + * pierre reaches because `shiki/bundle/full` builds its `createHighlighter` + * from three statically-referenced catalogs: + * + * @shikijs/langs 7,579,720 B (347 TextMate grammars) + * @shikijs/themes 1,330,336 B (65 themes) + * @shikijs/engine-oniguruma 638,722 B (base64 wasm) + * + * esbuild cannot tree-shake any of it: the catalogs are indexed by runtime + * string, and with `splitting: false` every `() => import('@shikijs/langs/x')` + * is inlined into the single output file. Code splitting is not an option + * either — the console's loader requires every registered `console:script` to + * export a `default` setup function, so extra chunks cannot be served. + * + * The plugins below narrow those three catalogs. Measured, cumulative: + * + * baseline 10,808,316 B + * + slimShiki (allowlist, no shiki themes) 5,138,416 B + * + oniguruma/wasm stubs 4,515,533 B + * + drop @pierre/theming's shiki collection 3,170,356 B + * + only pierre-dark / pierre-light 2,897,352 B + * + * Both guards at the bottom of this file exist because every plugin here + * reaches into a dependency's internals. `assertSizeBudget` catches a shiki + * bump that silently restores the full catalogs; `assertHighlighting` catches + * one that silently leaves us rendering unhighlighted plain text. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + import esbuild from 'esbuild' +/** + * Grammars the editor bundles. Everything else raises shiki's own + * "Language `x` not found" — loud, and caught by `assertHighlighting`. + * + * Chosen for what this repo actually opens: Rust workers, their TypeScript + * injected UI, and the config and doc files around them. `cpp` is deliberately + * absent — cpp.mjs plus cpp-macro.mjs are 626 KB between them, a fifth of the + * whole asset, for a language no iii worker is written in. Adding one back is + * a one-line change; re-run the build and the budget line prints what it cost. + */ +const HIGHLIGHT_LANGUAGES = [ + ['typescript', ['ts', 'cts', 'mts']], + ['tsx', []], + ['javascript', ['js', 'cjs', 'mjs']], + ['jsx', []], + ['json', []], + ['jsonc', []], + ['yaml', ['yml']], + ['toml', []], + ['rust', ['rs']], + ['python', ['py']], + ['go', []], + ['sql', []], + ['shellscript', ['bash', 'sh', 'zsh', 'shell']], + ['css', []], + ['html', []], + ['markdown', ['md']], + ['dockerfile', []], + ['ini', ['properties']], + ['xml', []], + ['diff', ['patch']], +] + +/** Themes the page can ask for. `DEFAULT_THEMES` is exactly these two. */ +const KEPT_PIERRE_THEMES = new Set(['pierre-dark', 'pierre-light']) + +/** + * Replace the `shiki` root entry with a fine-grained equivalent. + * + * `shiki` resolves to `shiki/bundle/full`, whose `createHighlighter` closes + * over `bundledLanguages`, `bundledThemes`, and an oniguruma default engine. + * This shim keeps that module's exact export surface — @pierre/diffs imports + * `bundledLanguages`, `createHighlighter`, `createJavaScriptRegexEngine`, + * `createOnigurumaEngine`, `codeToHtml`, `createCssVariablesTheme`, + * `getTokenStyleObject` and `stringifyTokenStyle` from it — while swapping the + * catalogs for the allowlist above. It is shiki's own documented fine-grained + * bundle pattern; pierre imports the full bundle internally, so the only place + * a consumer can opt into the narrow one is here. + * + * `bundledThemes` is empty on purpose: pierre resolves theme names through + * @pierre/theming (pierre-dark and pierre-light ship as @pierre/theme + * modules), never through shiki's catalog. + * + * The shim's own imports resolve from @pierre/diffs' directory, not this + * package's. That is what guarantees one physical `@shikijs/core` in the + * graph: resolving from here could pick a different copy than pierre's own + * `shiki/core` import, and two `@shikijs/core` module registries would mean + * the highlighter this shim constructs is not the one pierre's renderers + * expect. + */ +function slimShiki() { + const NAMESPACE = 'pierre-slim-shiki' + const ONIG_NAMESPACE = 'pierre-slim-shiki-onig' + const langEntries = HIGHLIGHT_LANGUAGES.map( + ([id, aliases]) => + `{id:${JSON.stringify(id)},name:${JSON.stringify(id)},aliases:${JSON.stringify(aliases)},` + + `import:()=>import(${JSON.stringify(`@shikijs/langs/${id}`)})}`, + ).join(',') + + const CONTENTS = ` +export * from '@shikijs/core' +import { createBundledHighlighter, createSingletonShorthands, guessEmbeddedLanguages } from '@shikijs/core' +import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from '@shikijs/engine-javascript' + +export const bundledLanguagesInfo = [${langEntries}] +export const bundledLanguagesBase = Object.fromEntries(bundledLanguagesInfo.map((l) => [l.id, l.import])) +export const bundledLanguagesAlias = Object.fromEntries( + bundledLanguagesInfo.flatMap((l) => l.aliases.map((a) => [a, l.import])), +) +export const bundledLanguages = { ...bundledLanguagesBase, ...bundledLanguagesAlias } + +export const bundledThemesInfo = [] +export const bundledThemes = {} + +export const createHighlighter = createBundledHighlighter({ + langs: bundledLanguages, + themes: bundledThemes, + engine: () => createJavaScriptRegexEngine(), +}) + +export const { + codeToHtml, codeToHast, codeToTokens, codeToTokensBase, + codeToTokensWithThemes, getSingletonHighlighter, getLastGrammarState, +} = createSingletonShorthands(createHighlighter, { guessEmbeddedLanguages }) + +export { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } +export function createOnigurumaEngine() { + throw new Error('@pierre/diffs: the oniguruma engine is not bundled — use preferredHighlighter "shiki-js"') +} +export function loadWasm() { + throw new Error('@pierre/diffs: the oniguruma wasm is not bundled') +} +` + + return { + name: 'pierre-slim-shiki', + setup(build) { + let pierreDir = null + + // @pierre/diffs is the only bare-`shiki` importer in the graph (its + // sibling @pierre/theming reaches for `shiki/core`, which is left + // alone), so the first importer's directory IS pierre's. One fixed + // path keeps a single module identity for the shim however many pierre + // files import `shiki`, which matters because the shim owns a + // highlighter singleton. + build.onResolve({ filter: /^shiki$/ }, (args) => { + pierreDir ??= args.resolveDir + return { path: 'root', namespace: NAMESPACE } + }) + + build.onLoad({ filter: /.*/, namespace: NAMESPACE }, () => ({ + contents: CONTENTS, + loader: 'js', + resolveDir: pierreDir, + })) + + // Nothing here selects the wasm engine: pierre defaults to + // `preferredHighlighter: 'shiki-js'` and the shim's engine factory is + // the JS one. Both specifiers are still statically reachable, so + // esbuild bundles the 638 KB wasm unless they are stubbed. The stubs + // throw rather than no-op so a future opt-in to 'shiki-wasm' fails + // where it is switched on instead of silently rendering plain text. + build.onResolve({ filter: /^shiki\/wasm$/ }, () => ({ path: 'wasm', namespace: ONIG_NAMESPACE })) + build.onResolve({ filter: /^shiki\/engine\/oniguruma$/ }, () => ({ + path: 'engine', + namespace: ONIG_NAMESPACE, + })) + build.onLoad({ filter: /.*/, namespace: ONIG_NAMESPACE }, (args) => + args.path === 'wasm' + ? { contents: 'export default undefined\n', loader: 'js' } + : { + contents: + 'const no = () => { throw new Error("@pierre/diffs: the oniguruma engine is not bundled") }\n' + + 'export const createOnigurumaEngine = no\nexport const loadWasm = no\n' + + 'export default { createOnigurumaEngine, loadWasm }\n', + loader: 'js', + }, + ) + }, + } +} + +/** + * Narrow the two theme catalogs @pierre/theming registers. + * + * `collections/shiki.js` maps 65 theme names to `@shikijs/themes/*` dynamic + * imports (1,330,336 B) and `shared_highlighter.js` registers every one of + * them at module scope, so the whole set is reachable even though the page + * only ever asks for pierre's own two. Replacing that collection with an + * empty one drops all of it. `collections/pierre.js` keeps its ten names + * registered, but the eight the page cannot select resolve to a throwing stub + * instead of a theme module each. + * + * Both interceptors reach past @pierre/theming's `exports` map into private + * files. `dist/collections/shiki.js` can move or be renamed on any patch + * release, at which point the filter stops matching — silently, because the + * build still succeeds and merely gets bigger. That is precisely what + * `assertSizeBudget` is for; `assertHighlighting` covers the mirror case + * where the shape changes and themes stop resolving at all. + */ +function slimPierreThemes() { + const NAMESPACE = 'pierre-slim-themes' + return { + name: 'pierre-slim-themes', + setup(build) { + build.onLoad({ filter: /[\\/]@pierre[\\/]theming[\\/]dist[\\/]collections[\\/]shiki\.js$/ }, () => ({ + contents: + 'import { createThemeCollection } from "../modules/createThemeCollection.js"\n' + + 'export const SHIKI_COLLECTION = "shiki"\n' + + 'export const SHIKI_THEMES = []\n' + + 'export const LIGHT_SHIKI_THEMES = []\n' + + 'export const DARK_SHIKI_THEMES = []\n' + + 'export const shikiThemes = createThemeCollection({ themes: [] })\n', + loader: 'js', + })) + + build.onResolve({ filter: /^@pierre\/theme\/[a-z-]+$/ }, (args) => { + const name = args.path.slice('@pierre/theme/'.length) + if (KEPT_PIERRE_THEMES.has(name)) return null + return { path: name, namespace: NAMESPACE } + }) + + build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => ({ + contents: + `throw new Error(${JSON.stringify( + `@pierre/theme/${args.path} is not bundled — the editor asset ships pierre-dark and pierre-light only`, + )})\n` + 'export default undefined\n', + loader: 'js', + })) + }, + } +} + +/** Rebuilt per invocation: an esbuild plugin closes over per-build state. */ +const plugins = () => [slimShiki(), slimPierreThemes()] + const options = { entryPoints: ['page.tsx', 'styles.css'], bundle: true, format: 'esm', jsx: 'automatic', outdir: 'dist', - external: [ - 'react', - 'react-dom', - 'react-dom/client', - 'react/jsx-runtime', - '@iii-dev/console-ui', - ], + external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@iii-dev/console-ui'], + plugins: plugins(), logLevel: 'info', } +/** + * The console refuses an asset over 8 MiB outright, so the point of a budget + * here is not to stay under the cap — it is to trip when an interceptor above + * silently stops matching. Each one was measured failing on its own, from a + * 2,092,035-byte baseline: + * + * pierre theme allowlist widened 2,364,628 B +272,593 + * oniguruma/wasm stubs bypassed 2,714,945 B +622,910 + * @pierre/theming collection moved 3,438,029 B +1,345,994 + * slimShiki bypassed entirely 10,580,197 B +8,488,162 + * + * Half the cap (4 MiB) would only catch the last of those, which makes it a + * cap check rather than a regression check. 2.5 MiB catches the bottom three + * and leaves 529,405 bytes — 25% — of headroom. + * + * Only the first is missed, and it cannot break anything: it is unreachable + * theme data, not a rendering path. Catching it too would mean a budget within + * 160 KB of the current size, which no page could grow inside. + * + * A grammar added to HIGHLIGHT_LANGUAGES can trip this. That is deliberate: a + * 600 KB language should have to raise the number in the same commit, where + * the cost is visible, instead of quietly spending headroom. + * + * Over budget is a build failure, not a warning — the alternative is a worker + * that registers and is then rejected by the console, which surfaces as a + * missing page rather than as a bundling problem. + */ +const SIZE_BUDGET_BYTES = 2.5 * 1024 * 1024 + +async function assertSizeBudget() { + const file = path.join('dist', 'page.js') + const bytes = (await readFile(file)).byteLength + const mib = (bytes / 1048576).toFixed(3) + if (bytes > SIZE_BUDGET_BYTES) { + throw new Error( + `${file} is ${bytes} bytes (${mib} MiB) — over the ${SIZE_BUDGET_BYTES}-byte budget.\n` + + 'Usually this means one of the shiki or theme interceptors in build.mjs stopped\n' + + 'matching: they reach into dependency internals, so a patch bump can move a path.\n' + + 'Run `node build.mjs --analyze` to see which package came back.\n' + + 'If the growth is a language you meant to add, raise SIZE_BUDGET_BYTES here.', + ) + } + console.log(`dist/page.js ${bytes} bytes (${mib} MiB) — within the ${SIZE_BUDGET_BYTES}-byte budget`) +} + +/** + * Prove the narrowed bundle still highlights. + * + * The size budget catches the catalogs coming back; this catches them going + * away. Every failure mode of the interceptors above is silent in the browser + * — pierre catches a rejected grammar load and renders the file as plain + * text, which reads as a styling bug rather than a build one. So the build + * loads the real bundled highlighter path and asserts a TypeScript snippet + * comes back as coloured spans under a pierre theme. + * + * Built separately for node: the page bundle is a browser ESM asset with React + * external, which cannot be imported here. It goes to a temp file rather than + * a `data:` URL because a throw inside the probe puts the module's URL in the + * stack trace, and a 2 MB base64 blob in the build log buries the one line + * that says what broke. + */ +async function assertHighlighting() { + const probe = await esbuild.build({ + stdin: { + contents: + "import { getSharedHighlighter, DEFAULT_THEMES } from '@pierre/diffs'\n" + + 'const highlighter = await getSharedHighlighter({\n' + + ' themes: [DEFAULT_THEMES.dark, DEFAULT_THEMES.light],\n' + + " langs: ['typescript'],\n" + + '})\n' + + "export const html = highlighter.codeToHtml('export const n: number = 1\\n', {\n" + + " lang: 'typescript',\n" + + ' theme: DEFAULT_THEMES.dark,\n' + + '})\n' + + 'export const themes = highlighter.getLoadedThemes()\n', + resolveDir: process.cwd(), + loader: 'ts', + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'silent', + plugins: plugins(), + }) + + const dir = await mkdtemp(path.join(os.tmpdir(), 'editor-ui-smoke-')) + try { + const file = path.join(dir, 'probe.mjs') + await writeFile(file, probe.outputFiles[0].text) + + let html + let themes + try { + ;({ html, themes } = await import(pathToFileURL(file).href)) + } catch (cause) { + // A grammar or theme the interceptors dropped fails here, inside pierre, + // with a message that already names it. Keep that message and say where + // it came from, so it does not read as an unrelated crash. + throw new Error(`highlighting smoke test failed: ${cause instanceof Error ? cause.message : cause}`) + } + + // A theme that failed to resolve and a grammar that never loaded both end + // the same way: one span, no inline colour. + const coloured = / | null { return typeof value === 'object' && value !== null ? (value as Record) : null @@ -48,12 +52,6 @@ function num(value: unknown): number | null { return typeof value === 'number' ? value : null } -function clampPatch(patch: string): { text: string; clipped: boolean } { - const lines = patch.split('\n') - if (lines.length <= MAX_PATCH_LINES) return { text: patch, clipped: false } - return { text: lines.slice(0, MAX_PATCH_LINES).join('\n'), clipped: true } -} - function Stat({ added, removed }: { added: number | null; removed: number | null }) { if (added === null && removed === null) return null return ( @@ -65,13 +63,40 @@ function Stat({ added, removed }: { added: number | null; removed: number | null ) } -function Patch({ patch }: { patch: string }) { - const { text, clipped } = clampPatch(patch) +/** + * A unified patch, rendered as a diff. + * + * Shown through `@pierre/diffs` rather than as diff-highlighted source: an + * agent's edit is the thing you most need to read at a glance, and a real diff + * gives it real add and delete colouring, hunk separation and the file's own + * line numbers. + * + * Parsed here rather than handed to pierre's `PatchDiff`, which throws unless + * the text yields exactly one file with one diff — that would take the whole + * chat message down. `parsePatchFiles` does not throw, so an unparseable + * patch degrades to nothing rendered instead. + */ +function Patch({ host, patch }: { host: Host; patch: string }) { + const themeType = host.useTheme() + const files = useMemo(() => parsePatchFiles(patch, `p${patch.length}`).flatMap((p) => p.files), [patch]) + if (files.length === 0) return null + + const shown = files.slice(0, MAX_PATCH_FILES) return (
- - {clipped && ( -
shown to the first {MAX_PATCH_LINES} lines — open the file to see the rest
+ {shown.map((file, index) => ( + + ))} + {files.length > shown.length && ( +
+ +{files.length - shown.length} more file{files.length - shown.length === 1 ? '' : 's'} — open the file to see + the rest +
)}
) @@ -122,7 +147,7 @@ function renderMove(output: Record) { ) } -function renderDiff(output: Record) { +function renderDiff(output: Record, host: Host) { if (output.identical === true) { return (
@@ -142,12 +167,12 @@ function renderDiff(output: Record) { return (
- +
) } -function renderSave(output: Record) { +function renderSave(output: Record, host: Host) { const path = str(output.path) if (path === null) return null if (output.conflict === true) { @@ -159,7 +184,7 @@ function renderSave(output: Record) { {path}
Not written — the file changed since it was opened.
- {patch !== null && } + {patch !== null && } ) } @@ -273,7 +298,7 @@ function renderHunks(output: Record) { ) } -export function createEditorTriggerRenderer(_host: Host): FunctionTriggerRenderer { +export function createEditorTriggerRenderer(host: Host): FunctionTriggerRenderer { return { id: 'editor', isMatch: (functionId: string) => HANDLED.has(functionId), @@ -292,9 +317,9 @@ export function createEditorTriggerRenderer(_host: Host): FunctionTriggerRendere case 'editor::move': return renderMove(output) case 'editor::diff': - return renderDiff(output) + return renderDiff(output, host) case 'editor::save': - return renderSave(output) + return renderSave(output, host) case 'editor::open': return renderOpen(output) case 'editor::find': diff --git a/editor/ui/src/page/index.tsx b/editor/ui/src/page/index.tsx index 46883718b..7d95886d1 100644 --- a/editor/ui/src/page/index.tsx +++ b/editor/ui/src/page/index.tsx @@ -1,5 +1,5 @@ /** - * The `#/ext/editor` page: a file tree, tabs, and the shared Monaco editor. + * The `#/ext/editor` page: a file tree, tabs, a reader and an editor. * * A **folder** is the unit, not a repository — tree, tabs, editor and search * all work in a plain directory, and git only adds a branch label, change @@ -15,11 +15,20 @@ * collapsible — at these widths, every pixel spent on chrome is taken from the * code. * - * Chrome note: the shared `CodeEditor` is intentionally bare (no line numbers, - * no glyph margin, no minimap), and the SOP forbids bundling another editor. - * So the page carries the chrome instead — the status line below the editor is - * where the language, size and git deltas live, which is the honest substitute - * for a gutter we cannot paint. + * Reading and writing are different jobs, so they use different surfaces: + * + * - **read** renders `@pierre/diffs`' `File` — real line numbers, a real + * syntax theme, and a file header. Render-only, which is exactly right for + * the job a file spends most of its time doing. + * - **edit** keeps the console's shared Monaco `CodeEditor`. It is + * intentionally bare (no line numbers, no glyph margin, no minimap) because + * it is built to be a form field, and the SOP forbids bundling a second + * editor to get the chrome back. It is still the only surface here that can + * accept a keystroke. + * + * Diffs are pierre's too: `editor::diff` and `editor::git::hunks` both return + * unified patch text, and pierre parses that into a real diff with its own + * add/delete colouring rather than the banded rows this page used to paint. */ import { @@ -36,6 +45,8 @@ import { Input, StatusDot, } from '@iii-dev/console-ui' +import { DEFAULT_THEMES, parsePatchFiles } from '@pierre/diffs' +import { File, FileDiff } from '@pierre/diffs/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -58,6 +69,26 @@ import { type ChangedEvent, useWorkspaceEvents } from '../lib/events' /** How long a row keeps its "just changed" accent after an edit lands. */ const FLASH_MS = 12_000 +/** + * pierre renders into its own shadow root and injects its stylesheet there + * from the resolved theme, so there is no CSS asset to ship for it and no + * `console:style` entry to add. What it does need is the theme *names* and + * which of the pair is live, which is why `themeType` is threaded down from + * the console's own light/dark state rather than left on 'system' — the page + * is inside the console's theme, not the OS's. + * + * `disableWorkerPool` is not a performance choice so much as an honest one: + * pierre's worker pool needs a `workerFactory` returning a real `Worker`, and + * a worker script would have to be a second URL the console serves. It only + * serves assets a worker registers as `console:script`, and its loader + * requires each of those to export a `default` setup function — a worker + * script has none. So highlighting runs on the main thread. For the file + * sizes this worker will open (bounded by `max_file_bytes`) that is a + * non-issue; a 10k-line file is where it would start to show. + */ +const READ_OPTIONS = { theme: DEFAULT_THEMES, overflow: 'scroll' } as const +const DIFF_OPTIONS = { theme: DEFAULT_THEMES, diffStyle: 'unified', overflow: 'scroll' } as const + /** Editor contents per open path. The worker owns *which* paths are open; the * text being typed is the one thing genuinely local until it is saved. */ interface Draft { @@ -76,6 +107,8 @@ interface Delta { export function EditorPage({ host }: { host: Host }) { const api = useMemo(() => createApi(host), [host]) + // Follows the console's own light/dark toggle, not the OS preference. + const themeType = host.useTheme() const [root, setRoot] = useState('') const [rootInput, setRootInput] = useState('') @@ -86,7 +119,9 @@ export function EditorPage({ host }: { host: Host }) { const [treeRoot, setTreeRoot] = useState(null) const [drafts, setDrafts] = useState>({}) const [activePath, setActivePath] = useState(null) - const [view, setView] = useState<'edit' | 'diff' | 'git'>('edit') + // Reading is the default: opening a file should show you the file, not put + // a cursor in it. + const [view, setView] = useState<'read' | 'edit' | 'diff' | 'git'>('read') const [mode, setMode] = useState<'files' | 'search'>('files') const [query, setQuery] = useState('') const [results, setResults] = useState([]) @@ -196,7 +231,7 @@ export function EditorPage({ host }: { host: Host }) { })) applyWorkspace(await api.workspace()) setActivePath(path) - setView('edit') + setView('read') } catch (e) { setError(errorText(e)) } @@ -647,6 +682,9 @@ export function EditorPage({ host }: { host: Host }) { <>
+ @@ -670,7 +708,9 @@ export function EditorPage({ host }: { host: Host }) {
)} - {view === 'edit' ? ( + {view === 'read' ? ( + + ) : view === 'edit' ? (
) : view === 'diff' ? ( - + ) : ( - + )}
@@ -794,6 +840,10 @@ export function EditorPage({ host }: { host: Host }) { Nothing was written. Below is the difference between what is on disk now and what you tried to save. + {/* The one patch still shown as highlighted source rather than as a + pierre diff. pierre sizes itself against a flex parent it can + scroll inside; a dialog is neither, and a diff that grows past + the dialog instead of scrolling in it is worse than this. */}
- + {/* + Markdown only. `MarkdownPreview` is the console's own + component, the documented preview counterpart to + `CodeEditor`, and iii-directory already renders skills, + prompts and registry READMEs through it. Reusing it + keeps this page consistent with those and costs nothing + in the bundle, since console-ui resolves at runtime. + */} + {markdown && ( + + )} + {/* + Only while there is something unsaved. This view diffs + the draft against what was last read, so on a clean file + it is guaranteed empty, and it was showing that empty + state most of the time. Note it never carries an agent's + edits either: those are written to disk, so they arrive + under `head`. Its one real job is reviewing your own + typing before you commit to it, which only exists while + you are mid-edit. + */} + {dirty && ( + + )} @@ -710,8 +918,14 @@ export function EditorPage({ host }: { host: Host }) { {view === 'read' ? ( + ) : view === 'preview' ? ( + // The draft, not the saved file, so the preview tracks what + // is being typed rather than what was last written. +
+ +
) : view === 'edit' ? ( -
+
) : view === 'diff' ? ( ({ name: path, contents, cacheKey: `${path}:${contents.length}` }), [contents, path]) + const file = useMemo(() => ({ name: path, contents, cacheKey: `${path}:${contentHash(contents)}` }), [contents, path]) return ( -
+
) @@ -897,14 +1111,14 @@ function FileView({ path, contents, themeType }: { path: string; contents: strin */ function PatchView({ patch, themeType }: { patch: string; themeType: 'light' | 'dark' }) { const files = useMemo( - () => (patch.trim() === '' ? [] : parsePatchFiles(patch, `p${patch.length}`).flatMap((p) => p.files)), + () => (patch.trim() === '' ? [] : parsePatchFiles(patch, `p${contentHash(patch)}`).flatMap((p) => p.files)), [patch], ) if (files.length === 0) return
no changes
return ( -
+
{files.map((file, index) => ( createApi(host), [host]) const [patch, setPatch] = useState(null) useEffect(() => { diff --git a/editor/ui/styles.css b/editor/ui/styles.css index f32934bdf..4dc9c1dbf 100644 --- a/editor/ui/styles.css +++ b/editor/ui/styles.css @@ -338,17 +338,42 @@ flex: 1; } -[data-iii-ui='editor'] .ed-surface { +/* One scroll contract for all four views. + * + * `read`, `edit`, `unsaved` and `head` render into the same slot, and every one + * of them hands that slot content taller than the pane: pierre grows + * vertically (its own `[data-code]` scrolls sideways and *clips* vertically), + * and the console's CodeEditor is documented as growing with its content with + * Monaco's own scrollbars switched off — "put it inside an `overflow-auto` + * pane". Neither one scrolls itself, so the pane has to, and there is no + * reason for four versions of that. + * + * `min-height: 0` is the other half and is not optional: a flex item defaults + * to `min-height: auto`, which refuses to shrink below its content, so the pane + * would grow to the height of the file and overflow the window instead of + * scrolling. It has to hold all the way up — .ed-root, .ed-body, .ed-main and + * here — because one ancestor without it defeats every descendant. + * + * Exactly one scrollbar comes out of this. Monaco's are configured off, and + * pierre's element stretches to the pane's width so its horizontal scroller + * never makes the pane overflow sideways as well. + */ +[data-iii-ui='editor'] .ed-pane { flex: 1; min-height: 0; + overflow: auto; margin: 0 8px; +} + +/* The edit view's box, on top of `.ed-pane`. Monaco is chrome-less, so the + border is what separates the code from the page. */ +[data-iii-ui='editor'] .ed-surface { border: 1px solid var(--color-rule); border-radius: 4px; - overflow: hidden; } -/* The scroll pane for everything @pierre/diffs renders — the read view and - both diff views. Only the box is ours: pierre paints inside a shadow root +/* Everything @pierre/diffs renders — the read view and both diff views — on + top of `.ed-pane`. Only the box is ours: pierre paints inside a shadow root and injects its own stylesheet there from the theme it was handed, so there is nothing here to colour and no CSS asset to ship for it. The gap is for the multi-file case, where each file is its own element. */ @@ -356,10 +381,6 @@ display: flex; flex-direction: column; gap: 8px; - flex: 1; - min-height: 0; - overflow: auto; - margin: 0 8px; } /* The status line carries what the *edit* surface cannot: the console's shared From 10d1f9a5ec455f97a2c04bff0f3dacfa02841797 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 30 Jul 2026 16:26:12 +0100 Subject: [PATCH 11/11] (MOT-4274) fix(editor): deny the hook target, and make the docs match the code editor::on-file-change was left agent-callable. It is a harness::hook::post-trigger target meant to be fired engine-side, and a model that called it directly could forge an editor::changed event into every subscribed console tab: path, cause, kind and patch all come straight off the payload. Every comparable internal bridge target in the file is denied, and this one now is too. editor::git::show was neither allowlisted nor in the needs-approval enumeration despite being read-only and routinely useful, so it joins git::status and git::hunks. The prefix strip in relative() and relative_to() matched by string rather than by segment, so a workspace root of /srv/app turned /srv/application/a.rs into lication/a.rs: a plausible-looking path resolving nowhere, reported as the file that changed. That is the defect Session::remap was written to avoid, and it survived because there were two copies of it. Now one, with a test that fails without the fix. The rest is documentation that had drifted behind the code. The README said the page polls the working tree, which the push rewrite removed; it described a hand-rolled diff renderer that no longer exists and a two-way toggle where there are now five views; and it used relative links that escape the worker folder, which break when the README renders on the registry. The skill taught expected_mtime as the save guard without mentioning the content version that supersedes it, and carried neither of the two sections the repo requires: a function list, and the reactive trigger this worker registers. diff_context_lines was documented as applying to hunks, which ignores it and defaults to 0; only editor::diff reads it, and that description ships into the console config panel. One comment named config.yaml as where an operator writes their intent, when that file is an engine config the seed parser rejects by design. --- editor/README.md | 84 +++++++++++----- editor/skills/SKILL.md | 104 ++++++++++++++++--- editor/src/config.rs | 50 +++++++++- editor/src/configuration.rs | 69 ++++++++++++- editor/src/diff.rs | 58 +++++++++++ editor/src/functions/mod.rs | 152 ++++++++++++++++++++++++++-- editor/src/functions/types.rs | 182 ++++++++++++++++++++++++++++++++++ editor/src/fuzzy.rs | 72 ++++++++++++-- editor/src/git.rs | 160 ++++++++++++++++++++++++++++++ editor/src/lang.rs | 42 ++++++++ editor/src/lib.rs | 9 +- editor/src/observe.rs | 97 ++++++++++++++++++ editor/src/tree.rs | 90 ++++++++++++++++- editor/src/workspace.rs | 52 +++++++++- editor/tests/integration.rs | 12 ++- iii-permissions.yaml | 6 ++ 16 files changed, 1166 insertions(+), 73 deletions(-) diff --git a/editor/README.md b/editor/README.md index 77f465c79..bd75a4e60 100644 --- a/editor/README.md +++ b/editor/README.md @@ -1,7 +1,7 @@ # editor A code workspace that an agent and a person share. Open a folder, and the -buffers you have open, the folders you have expanded, and the mtimes each +buffers you have open, the folders you have expanded, and the version each buffer was read at are one record on the bus — so the file an agent opens appears in your tabs, and the file you open is one the agent can see. @@ -11,8 +11,9 @@ marks when the root happens to be a repo, and nothing else changes when it is not. It opens no files itself. Reads, writes, moves, listings and `git` all go -through the [`shell`](../shell/) worker, so shell's jail and denylist are the -only filesystem boundary; the workspace record lives in [`state`](../state/). +through the [`shell`](https://github.com/iii-hq/workers/tree/main/shell) worker, +so shell's jail and denylist are the only filesystem boundary; the workspace +record lives in [`state`](https://github.com/iii-hq/workers/tree/main/state). What `editor` adds is the model on top: diffing, ranking paths, refusing a stale write, and keeping open buffers correct when a folder moves under them. @@ -28,9 +29,9 @@ iii worker add state # required — the workspace record lives here | Worker | Why | |---|---| -| [`shell`](../shell/) | Required. Every read, write, move, listing (`coder::tree`) and `git` invocation. Its `fs.host_roots` jail governs which paths `editor` can reach. | -| [`state`](../state/) | Required. Holds the active root and one session per project (open buffers, expanded folders). | -| [`console`](../console/) | Optional. Renders the `#/ext/editor` page and the `editor::*` chat cards. | +| [`shell`](https://github.com/iii-hq/workers/tree/main/shell) | Required. Every read, write, move, listing (`coder::tree`) and `git` invocation. Its `fs.host_roots` jail governs which paths `editor` can reach. | +| [`state`](https://github.com/iii-hq/workers/tree/main/state) | Required. Holds the active root and one session per project (open buffers, expanded folders). | +| [`console`](https://github.com/iii-hq/workers/tree/main/console) | Optional. Renders the `#/ext/editor` page and the `editor::*` chat cards. | ## Quickstart @@ -79,8 +80,8 @@ async fn main() -> anyhow::Result<()> { | `editor::workspace::open` | Point the workspace at a folder. Returns the buffers and expanded folders remembered for it. | | `editor::workspace::get` | The active root, open buffers, and expanded folders — what every surface sees. | | `editor::tree` | List a folder, with the workspace's expansion state. The walk, the noise-folder excludes and the jail are shell's. | -| `editor::open` | Read a text file and record it as an open buffer, with its language id and the mtime to save against. | -| `editor::save` | Whole-file write, refused when the file moved since the open it started from. The refusal carries the disk-vs-yours diff. | +| `editor::open` | Read a text file and record it as an open buffer, with its language id and the content version to save against. | +| `editor::save` | Whole-file write, refused when the file changed since the open it started from. The refusal carries the disk-vs-yours diff. | | `editor::buffers::list` | Files currently open. | | `editor::buffers::close` | Close one buffer. The file on disk is untouched. | | `editor::move` | Move or rename, then rewrite every open buffer and expanded folder at or under the path. | @@ -108,17 +109,47 @@ exists because `shell::fs::mv` alone leaves open buffers pointing at the old path — the next save then writes them back there, silently recreating the folder that was just moved. +## Custom trigger types + +| Trigger type | Fires when | Payload | +|---|---|---| +| `editor::changed` | A file in the workspace changed, whoever changed it | `path`, `cause` (the function id that did it), `kind` (`created` \| `modified` \| `deleted` \| `unknown`), `added`, `removed`, `patch`, `truncated`, `root` | + +The event is how a surface follows an agent without polling. It does not +require the agent to cooperate: the worker binds a `harness::hook::post-trigger` +hook on the `shell::*` and `coder::*` write paths, so an edit made by anything +becomes an event. The hook is advisory and fail-open — it never delays or +denies the write that produced it. + +```rust +use iii_sdk::protocol::RegisterTriggerInput; + +iii.register_trigger(RegisterTriggerInput { + trigger_type: "editor::changed".to_string(), + function_id: "my-worker::on-edit".to_string(), + config: serde_json::json!({}), + metadata: None, +})?; +``` + +Bindings take no config. Delivery is fire-and-forget: a slow or absent +subscriber is logged and skipped. `patch` is capped at 16 KiB with `truncated` +set — ask `editor::git::hunks` when you need the whole thing. + ## Console page `#/ext/editor` is a view over the same workspace: a collapsible file tree on -the left with a files/search switch, tabs and the shared Monaco editor on the -right, an edit/diff toggle, and a save that surfaces the conflict guard as a -dialog. A status line under the editor carries the file's language, line count -and git deltas — the shared `CodeEditor` is deliberately chrome-less (no line -numbers, no glyph margin) and the SOP forbids bundling another editor, so that -line is where a gutter's information goes. A git strip along the bottom does -commit, fetch, pull, push, stash and pop. Folder expansion round-trips through the worker, so it -survives a reload and both surfaces agree on it. It polls the working tree, so a +the left with a files/search switch, tabs on the right, and a save that +surfaces the conflict guard as a dialog. The open file has its own view strip +— `read`, `edit`, `preview` on a markdown file, `unsaved` while there is +something unsaved, and `head` for the diff against the last commit. A status +line under it carries the path, language, line count, git deltas, saved state +and the most recent observed edit. A git strip along the bottom does commit, +fetch, pull, push, stash and pop. Folder expansion round-trips through the +worker, so it survives a reload and both surfaces agree on it. + +Nothing is polled: one read on mount seeds the git overlay, and after that the +page reacts to `editor::changed` and to the workspace's own `state` scope. So a file an agent edits lights up as the edit lands and an open tab you have not typed in reloads under you. A tab you *have* edited is never reloaded; it is flagged, and the conflict guard decides the outcome. @@ -126,15 +157,16 @@ flagged, and the conflict guard decides the outcome. `editor::*` calls also render as themselves in chat and traces: a diff as a diff, a save as a file card with its line counts. -The diff view is rendered by the worker rather than borrowed from the -console's own diff cards. Those are backed by a library that is already -loaded in the console but is not exposed through `@iii-dev/console-ui`, and -bundling a second copy into this worker's asset measures at 10.3 MB, over -the 8 MiB per-asset cap. So the page draws its own: one row per line, added -and removed lines banded, hunk headers, and the file's real line numbers -taken from the `@@` headers. It gives up syntax highlighting inside the -diff. If a shared diff component is ever added to `@iii-dev/console-ui`, -this page should use it and drop the local renderer. +Files and diffs are drawn with [`@pierre/diffs`](https://www.npmjs.com/package/@pierre/diffs) +— real line numbers, a syntax theme, and its own add/delete colouring, all +inside its own shadow root. `editor::diff` and `editor::git::hunks` already +return unified patch text, which is exactly what it parses. Editing stays on +the console's shared Monaco `CodeEditor`: it is the one editing surface, and +the SOP forbids bundling a second editor to get chrome back. Bundling +`@pierre/diffs` as-is costs 10.3 MB — over the console's 8 MiB per-asset cap, +and almost all of it shiki's full grammar and theme catalogs — so `ui/build.mjs` +narrows those catalogs to what this worker opens and asserts both the size +budget and that highlighting still works. ## Configuration @@ -157,7 +189,7 @@ Every field is a bound. Nothing here grants access — that is `shell`'s config. ```yaml max_diff_bytes: 2000000 # per side of editor::diff, bytes -diff_context_lines: 3 # unchanged lines kept around each hunk +diff_context_lines: 3 # editor::diff's default context; hunks defaults to 0 find_limit: 50 # rows returned by editor::find max_find_candidates: 50000 # paths scanned per editor::find call max_file_bytes: 2000000 # largest file editor::open will pull back diff --git a/editor/skills/SKILL.md b/editor/skills/SKILL.md index 9668b34ff..f4ffa8d38 100644 --- a/editor/skills/SKILL.md +++ b/editor/skills/SKILL.md @@ -42,7 +42,8 @@ tell the user what you are doing. their editor, which is better than pasting the file into the conversation. - You need to know what they are looking at (`editor::workspace::get`). - You are editing across several turns and must not clobber a concurrent edit - (`editor::open` for the mtime, then `editor::save` with `expected_mtime`). + (`editor::open` for the `version`, then `editor::save` with + `expected_version`). - You are renaming or moving something (`editor::move` — never `shell::fs::mv` when buffers may be open; see below). - You know roughly what a file is called but not where it lives @@ -61,8 +62,9 @@ tell the user what you are doing. - `editor::find` matches **paths**; `editor::search` matches **contents**. Listing a directory outside the workspace is still `shell::fs::ls`. -- Not a full git client. Status, hunks, tracked paths, commit, fetch/pull/push, - stash and undo-last-commit are covered. Anything else — branch, checkout, +- Not a full git client. Status, hunks, a file at a revision, tracked paths, + commit, fetch/pull/push, stash and undo-last-commit are covered. Anything + else — branch, checkout, rebase, cherry-pick, remote management — goes through `shell::exec`. `editor::git::sync` pulls `--ff-only`; a merge is deliberately not offered, because a conflicted tree under open buffers is a mess an editor cannot @@ -73,19 +75,59 @@ tell the user what you are doing. the complete new content, then save it. - Binary files are refused, not mangled. -## The two rules that prevent data loss - -**Save against the mtime you opened at.** +## Functions + +- `editor::workspace::open` — point the workspace at a folder; returns the + buffers and expanded folders remembered for it. +- `editor::workspace::get` — the active root, open buffers and expanded + folders, as every surface sees them. +- `editor::tree` — list a folder, carrying and persisting expansion state. +- `editor::open` — read a text file and record it as an open buffer. +- `editor::save` — whole-file write, guarded against a concurrent change. +- `editor::buffers::list` — the tab set. +- `editor::buffers::close` — close one buffer; the file on disk is untouched. +- `editor::move` — move or rename, rewriting every buffer and expanded folder + at or under the path. +- `editor::create` — create a file or folder, parents included. +- `editor::delete` — remove a path and close any buffer it held. +- `editor::find` — fuzzy file finder over paths, ranked basename-first. +- `editor::search` — search file contents, grouped by file. +- `editor::diff` — unified patch between two texts; pure, nothing is read. +- `editor::git::status` — branch, upstream, ahead/behind, one row per changed + path. +- `editor::git::hunks` — what changed in one file, as ranges plus a patch. +- `editor::git::show` — a file's contents at a revision, HEAD by default. +- `editor::git::commit` — stage and commit. +- `editor::git::sync` — fetch, fast-forward pull, or push. +- `editor::git::stash` — stash the working tree, or pop the most recent stash. +- `editor::git::undo-commit` — undo the last commit, keeping its changes + staged. + +Every path is root-relative unless it is absolute. The `editor::git::*` +functions fail outside a repository, which is an absent overlay rather than a +broken workspace. -1. `editor::open` returns `mtime`. -2. Pass it back as `expected_mtime` on `editor::save`. -3. If the file changed in between, **nothing is written**: the response carries - `conflict: true`, the current `disk_mtime`, and `conflict_patch` — a diff - from what is on disk now to what you tried to write. +## The two rules that prevent data loss -Re-open, reconcile against that patch, save again with the fresh mtime. Do not -retry with `expected_mtime` omitted to force it through; that is exactly the -clobber the guard exists to prevent. Omit it only when creating a new file. +**Save against the version you opened at.** + +1. `editor::open` returns `version` (an opaque version of the content) and + `mtime`. +2. Pass `version` back as `expected_version` on `editor::save`. Prefer it to + `expected_mtime`: mtime resolution is one second, so two writes inside the + same second are indistinguishable and the later one wins silently. + `expected_mtime` still works and is honoured when `expected_version` is + absent; when both are sent, `expected_version` is the guard. +3. If the content changed in between, **nothing is written**: the response + carries `conflict: true`, the current `disk_version` and `disk_mtime`, and + `conflict_patch` — a diff from what is on disk now to what you tried to + write. + +Re-open, reconcile against that patch, and save again with the fresh version — +or use the `version` a successful `editor::save` returns as the guard for the +next one, without re-opening. Do not retry with the guard omitted to force it +through; that is exactly the clobber it exists to prevent. Omit it only when +creating a new file. **Move through `editor::move`, not `shell::fs::mv`.** @@ -117,3 +159,37 @@ was just moved. - `editor::git::show` — `exists: false` with empty content means the path is absent at that revision, which is what a newly added file looks like. It is not an error. + +## Reactive triggers + +The worker publishes one custom trigger type, `editor::changed`, which fires +after a file in the workspace changes — whoever changed it. It does not require +the writer to have called this worker: a `harness::hook::post-trigger` hook on +the `shell::*` and `coder::*` write paths turns any filesystem call into an +event. The hook is advisory and fail-open, so it never delays or denies the +write that produced it. + +Bind it when a *different* worker or surface should follow edits as they land: +mirroring the workspace into a viewer, reacting to an agent's writes without +polling `editor::git::status`, or annotating a file the moment it moves. + +Do not bind when you made the write yourself — `editor::save` already returns +`added`, `removed` and the new `version`. + +### How to bind + +1. Register a handler: `registerFunction('my-worker::on-edit', handler)`. +2. Register the trigger: + +```typescript +iii.registerTrigger({ + type: 'editor::changed', + function_id: 'my-worker::on-edit', +}) +``` + +Bindings take no config, and every subscriber gets every event. Delivery is +fire-and-forget: a slow or absent subscriber is logged and skipped rather than +retried. The event's `patch` is capped and sets `truncated` when it was cut — +call `editor::git::hunks` when you need the whole diff. For the payload shape, +call `get function info` on the trigger type. diff --git a/editor/src/config.rs b/editor/src/config.rs index 803a7055f..b2a497597 100644 --- a/editor/src/config.rs +++ b/editor/src/config.rs @@ -26,7 +26,9 @@ pub struct WorkerConfig { #[serde(default = "default_max_diff_bytes")] pub max_diff_bytes: usize, - /// Unchanged lines kept around each hunk when a caller does not say. + /// Unchanged lines `editor::diff` keeps around each hunk when the caller + /// does not say. `editor::git::hunks` is not covered: it defaults to 0, so + /// its ranges stay exactly the lines that changed. #[serde(default = "default_diff_context_lines")] pub diff_context_lines: usize, @@ -213,8 +215,54 @@ mod tests { assert_eq!(cfg.find_limit, 9); } + #[test] + fn several_placeholders_expand_in_one_pass() { + std::env::set_var("EDITOR_TEST_FIND_LIMIT", "11"); + std::env::set_var("EDITOR_TEST_CONTEXT", "22"); + let cfg = WorkerConfig::from_yaml( + "find_limit: ${EDITOR_TEST_FIND_LIMIT}\ndiff_context_lines: ${EDITOR_TEST_CONTEXT}\n", + ) + .expect("both placeholders resolve"); + assert_eq!((cfg.find_limit, cfg.diff_context_lines), (11, 22)); + } + + /// An unset variable expands to nothing, which leaves the key with no + /// value at all. That must fail the parse rather than land as a zero — a + /// `find_limit` of 0 returns nothing and a `max_file_bytes` of 0 truncates + /// every open, and both would look like the worker was working. + #[test] + fn an_unset_variable_fails_the_parse_rather_than_zeroing_a_limit() { + assert!( + std::env::var("EDITOR_TEST_DEFINITELY_UNSET").is_err(), + "the fixture depends on this name being unset" + ); + assert!(WorkerConfig::from_yaml("find_limit: ${EDITOR_TEST_DEFINITELY_UNSET}").is_err()); + } + #[test] fn an_unterminated_placeholder_is_left_alone() { assert_eq!(expand_env("a ${UNCLOSED"), "a ${UNCLOSED"); } + + /// Every shipped default has to be usable as it stands. A bound of zero + /// does not mean "unbounded" anywhere in this worker — it means the feature + /// it bounds returns nothing. + #[test] + fn no_shipped_default_is_zero() { + let d = WorkerConfig::default(); + for (name, value) in [ + ("max_diff_bytes", d.max_diff_bytes as u64), + ("diff_context_lines", d.diff_context_lines as u64), + ("find_limit", d.find_limit as u64), + ("max_find_candidates", d.max_find_candidates as u64), + ("max_file_bytes", d.max_file_bytes as u64), + ("search_max_matches", d.search_max_matches), + ("git_timeout_ms", d.git_timeout_ms), + ] { + assert!( + value > 0, + "{name} ships as zero, which disables what it bounds" + ); + } + } } diff --git a/editor/src/configuration.rs b/editor/src/configuration.rs index 1ce41eb16..7b4d2954c 100644 --- a/editor/src/configuration.rs +++ b/editor/src/configuration.rs @@ -141,9 +141,9 @@ async fn trigger_with_retry( /// What to serve when the boot handshake never completed. /// -/// The seed, whenever there is one. `config.yaml` is where an operator wrote -/// their intent, and falling back to `WorkerConfig::default()` while a seed -/// exists would silently loosen every limit they had tightened — a safety +/// The seed, whenever there is one. The `--config` file is where an operator +/// wrote their intent, and falling back to `WorkerConfig::default()` while a +/// seed exists would silently loosen every limit they had tightened — a safety /// regression dressed as a resilience fix. With no seed at all nothing was /// configured on this side, so the shipped baseline genuinely is the operator's /// choice. @@ -360,6 +360,69 @@ mod tests { assert_eq!(source, SOURCE_DEFAULTS); } + /// A reload has to publish BOTH halves of the snapshot. The bus reads the + /// git timeout off an atomic, on a path with no access to the cell, so a + /// swap that updated only the cell would leave every git invocation on the + /// boot-time timeout — the one field a reload silently skipped. + #[tokio::test] + async fn applying_a_config_publishes_the_snapshot_and_the_git_timeout() { + let boot = WorkerConfig::default(); + let git_timeout = Arc::new(AtomicU64::new(boot.git_timeout_ms)); + let live = cell(boot.clone()); + + let next = WorkerConfig { + git_timeout_ms: boot.git_timeout_ms + 1_000, + max_file_bytes: 123, + ..WorkerConfig::default() + }; + assert_ne!(next, boot, "the fixture has to differ to prove anything"); + + apply_config(&live, &git_timeout, next.clone()).await; + + assert_eq!( + **live.read().await, + next, + "the next call of every handler reads the new snapshot" + ); + assert_eq!( + git_timeout.load(Ordering::Relaxed), + next.git_timeout_ms, + "the bus takes its timeout from the atomic, not from the cell" + ); + } + + /// The boot fallback is a stopgap, not a ceiling. Once the handshake + /// recovers, the authoritative values replace the seed through exactly the + /// path a hot reload uses — including the timeout the seed had set. + #[tokio::test] + async fn the_authoritative_config_replaces_a_boot_fallback() { + let seed = WorkerConfig { + max_file_bytes: 64_000, + git_timeout_ms: 3_000, + ..WorkerConfig::default() + }; + let (booted, source) = fallback_config(Some(&seed)); + assert_eq!(source, SOURCE_SEED); + + let git_timeout = Arc::new(AtomicU64::new(booted.git_timeout_ms)); + let live = cell(booted); + assert_eq!(git_timeout.load(Ordering::Relaxed), 3_000); + + let authoritative = WorkerConfig { + max_file_bytes: 9_000_000, + git_timeout_ms: 45_000, + ..WorkerConfig::default() + }; + apply_config(&live, &git_timeout, authoritative.clone()).await; + + assert_eq!(**live.read().await, authoritative); + assert_eq!( + git_timeout.load(Ordering::Relaxed), + 45_000, + "the seed's timeout must not outlive the recovery" + ); + } + #[test] fn recovery_backoff_grows_then_caps() { assert_eq!(recovery_backoff(1), Duration::from_millis(500)); diff --git a/editor/src/diff.rs b/editor/src/diff.rs index 104ce9177..3775509a7 100644 --- a/editor/src/diff.rs +++ b/editor/src/diff.rs @@ -239,6 +239,64 @@ mod tests { assert!(r.patch.is_empty()); } + /// The mirror of the pure-insertion case, and the other half of the + /// start-at-zero branch: an emptied file numbers its "after" side from 0. + #[test] + fn deleting_every_line_starts_the_new_side_at_zero() { + let r = diff("hello\nworld\n", "", None, 3, MAX); + assert_eq!(r.hunks.len(), 1); + assert_eq!(r.hunks[0].new_start, 0); + assert_eq!(r.hunks[0].new_lines, 0); + assert_eq!(r.hunks[0].old_start, 1); + assert_eq!((r.added, r.removed), (0, 2)); + assert!(!r.identical); + } + + /// `max_bytes` is a ceiling, not an exclusive bound. Pinning both sides of + /// it keeps a refactor from turning "as big as allowed" into "too big". + #[test] + fn input_exactly_at_the_cap_is_still_diffed() { + let r = diff("abcd\n", "abce\n", None, 3, 5); + assert!(!r.truncated, "five bytes must pass a five-byte cap"); + assert_eq!((r.added, r.removed), (1, 1)); + + let over = diff("abcde\n", "abce\n", None, 3, 5); + assert!(over.truncated, "six bytes must not"); + } + + /// `None` renders the documented placeholder label rather than an empty + /// header, so the patch stays readable and applyable. + #[test] + fn a_missing_path_renders_the_placeholder_label() { + let r = diff("a\n", "b\n", None, 3, MAX); + assert!(r.patch.contains("--- a/file")); + assert!(r.patch.contains("+++ b/file")); + } + + /// The patch body is assembled by pushing bytes; multibyte content must + /// come through it intact rather than sliced. + #[test] + fn multibyte_content_survives_the_patch_body() { + let r = diff( + "héllo → wörld\n", + "héllo ← wörld\n", + Some("i18n.txt"), + 3, + MAX, + ); + assert_eq!((r.added, r.removed), (1, 1)); + assert!(r.patch.contains("+héllo ← wörld")); + assert!(r.patch.contains("-héllo → wörld")); + } + + #[test] + fn two_empty_texts_are_identical() { + let r = diff("", "", None, 3, MAX); + assert!(r.identical); + assert!(!r.truncated); + assert!(r.patch.is_empty()); + } + #[test] fn context_zero_yields_tight_hunks() { let before = "a\nb\nc\nd\ne\n"; diff --git a/editor/src/functions/mod.rs b/editor/src/functions/mod.rs index 0b770c696..7482d909a 100644 --- a/editor/src/functions/mod.rs +++ b/editor/src/functions/mod.rs @@ -917,6 +917,70 @@ mod tests { )); } + /// A buffer persisted before versions existed loads with an empty one, and + /// an empty version is not the same as no version: it matches nothing on + /// disk, so it refuses. That is the safe direction — the caller who has no + /// version to offer must omit the field, which is what puts it back on the + /// mtime guard, and it is what the console page does. + #[test] + fn an_empty_version_refuses_rather_than_overwrites() { + let on_disk = content_version("whatever is there now\n"); + assert!( + conflicted(Some(""), Some(42), &on_disk, 42), + "an unknown version must never be read as a matching one" + ); + assert!( + !conflicted(None, Some(42), &on_disk, 42), + "omitting the version is what falls back to the mtime" + ); + } + + /// The four request shapes `editor::save` branches on, at the one function + /// that decides between them. + #[test] + fn the_guard_matrix_is_exhaustive() { + let started_from = content_version("as read\n"); + let unchanged = content_version("as read\n"); + let moved_on = content_version("edited elsewhere\n"); + + // (expected_version, expected_mtime, disk_version, disk_mtime) → refused? + for (label, refused, ev, em, dv, dm) in [ + ( + "version matches", + false, + Some(&started_from), + None, + &unchanged, + 10, + ), + ( + "version differs", + true, + Some(&started_from), + None, + &moved_on, + 10, + ), + ( + "version matches, mtime moved", + false, + Some(&started_from), + Some(1), + &unchanged, + 99, + ), + ("mtime only, matches", false, None, Some(10), &moved_on, 10), + ("mtime only, differs", true, None, Some(9), &moved_on, 10), + ("neither armed", false, None, None, &moved_on, 10), + ] { + assert_eq!( + conflicted(ev.map(String::as_str), em, dv, dm), + refused, + "{label}" + ); + } + } + /// The catalog is what ships to the registry; a function registered but /// left out of it would have no published schema. #[test] @@ -1088,15 +1152,10 @@ fn group_matches(raw: &serde_json::Value, root: &str) -> SearchOutput { /// Strip the workspace root from an absolute path, leaving anything that does /// not sit under it untouched. fn relative_to(path: &str, root: &str) -> String { - if root == "." { - return path.to_string(); - } - let trimmed = root.trim_end_matches('/'); - path.strip_prefix(trimmed) - .map(|rest| rest.trim_start_matches('/')) - .filter(|rest| !rest.is_empty()) - .unwrap_or(path) - .to_string() + // One implementation, because there were two and they carried the same bug: + // a root that ends mid-segment is not a parent. `observe::relative` also + // handles an empty root, which this copy did not. + crate::observe::relative(path, root) } /// Cut a string to at most `max_bytes`, never mid-codepoint. @@ -1352,6 +1411,32 @@ mod parity_tests { ); } + #[test] + fn a_root_with_a_trailing_slash_still_strips() { + let raw = json!({ + "matches": [ { "path": "/repo/src/a.rs", "line": 1, "content": "x" } ] + }); + assert_eq!(group_matches(&raw, "/repo/").files[0].path, "src/a.rs"); + } + + /// A hit on the root itself must not come back as an empty path — nothing + /// downstream can open one. + #[test] + fn a_path_that_is_the_root_is_left_alone() { + let raw = json!({ "matches": [ { "path": "/repo", "line": 1, "content": "x" } ] }); + assert_eq!(group_matches(&raw, "/repo").files[0].path, "/repo"); + } + + /// shell answering with no `matches` key at all: an empty result, not a + /// panic and not a phantom row. + #[test] + fn a_response_without_matches_is_an_empty_result() { + let out = group_matches(&json!({}), "/repo"); + assert!(out.files.is_empty()); + assert_eq!(out.total, 0); + assert!(!out.truncated, "an absent flag reads as not truncated"); + } + /// The case that used to panic: a cap landing inside a multibyte char. #[test] fn truncate_never_splits_a_codepoint() { @@ -1381,6 +1466,38 @@ mod parity_tests { } } + /// Four bytes is the widest a character gets, so it is the widest the + /// back-off has to walk: every one of the three interior offsets must land + /// on the same boundary rather than panic. + #[test] + fn truncate_backs_off_every_byte_of_a_four_byte_character() { + // U+1D11E MUSICAL SYMBOL G CLEF: four bytes, behind one ASCII byte. + let text = "a𝄞".to_string(); + assert_eq!(text.len(), 5); + for cap in 1..=4 { + assert_eq!( + truncate_on_boundary(text.clone(), cap), + "a", + "a cap of {cap} lands inside the codepoint and must cut back before it" + ); + } + assert_eq!(truncate_on_boundary(text, 5), "a𝄞"); + } + + /// The same sweep over a string that is entirely four-byte characters: + /// every cut has to land on a multiple of four. + #[test] + fn truncate_survives_an_all_four_byte_string() { + let text = "𝄞𝄞𝄞".to_string(); + assert_eq!(text.len(), 12); + for cap in 0..=text.len() { + let cut = truncate_on_boundary(text.clone(), cap); + assert!(cut.len() <= cap, "cap {cap} overshot"); + assert!(text.starts_with(&cut), "cap {cap} produced a non-prefix"); + assert_eq!(cut.len() % 4, 0, "cap {cap} cut inside a codepoint"); + } + } + #[test] fn summarize_keeps_stderr_when_stdout_is_empty() { let out = crate::bus::ExecOutcome { @@ -1404,6 +1521,23 @@ mod parity_tests { }; assert_eq!(summarize(&out), "out\nerr"); } + + /// The other half of the pair: a commit says everything on stdout, and a + /// whitespace-only stderr must not add a trailing blank line to it. + #[test] + fn summarize_keeps_stdout_when_stderr_is_blank() { + let out = crate::bus::ExecOutcome { + stdout: " [main abc1234] a message\n".to_string(), + stderr: " \n".to_string(), + ..crate::bus::ExecOutcome::default() + }; + assert_eq!(summarize(&out), "[main abc1234] a message"); + } + + #[test] + fn summarize_of_two_silent_streams_is_empty() { + assert_eq!(summarize(&crate::bus::ExecOutcome::default()), ""); + } } fn register_git_show(iii: &Arc, bus: &Arc) { diff --git a/editor/src/functions/types.rs b/editor/src/functions/types.rs index 4773a4e76..fdab00ca6 100644 --- a/editor/src/functions/types.rs +++ b/editor/src/functions/types.rs @@ -421,6 +421,188 @@ pub struct GitShowOutput { pub exists: bool, } +#[cfg(test)] +mod request_tests { + //! What a caller actually gets when it leaves a field out, and what + //! spellings the wire accepts. The golden schemas pin the *shape* of these + //! requests; these pin what deserializing one does — a default that flipped + //! would change behaviour without moving a single line of the schema's + //! `type`/`required` structure. + + use super::*; + use serde_json::json; + + /// Defaults are behaviour, not decoration: `stage_all` decides whether a + /// commit sweeps up unstaged work, and `include_untracked` decides whether + /// the file an agent just wrote is findable at all. + #[test] + fn omitted_fields_take_their_documented_defaults() { + let commit: GitCommitInput = + serde_json::from_value(json!({ "message": "m" })).expect("a message is enough"); + assert!( + commit.stage_all, + "a commit stages everything unless told not to" + ); + assert!(commit.cwd.is_none()); + + let find: FindInput = + serde_json::from_value(json!({ "query": "q" })).expect("a query is enough"); + assert!( + find.include_untracked, + "a just-created file has to be findable" + ); + assert!(find.limit.is_none()); + + let create: CreateInput = + serde_json::from_value(json!({ "path": "a.rs" })).expect("a path is enough"); + assert!(matches!(create.kind, EntryKind::File), "a file by default"); + assert!(create.content.is_none()); + + let hunks: GitHunksInput = + serde_json::from_value(json!({ "path": "a.rs" })).expect("a path is enough"); + assert!(matches!(hunks.against, Against::Worktree)); + assert_eq!( + hunks.context_lines, None, + "the handler reads absent as -U0; any context here widens every gutter range" + ); + + let tree: TreeInput = serde_json::from_value(json!({})).expect("tree takes no arguments"); + assert!(tree.path.is_none() && tree.max_depth.is_none()); + assert!(tree.expand.is_empty() && tree.collapse.is_empty()); + + let search: SearchInput = + serde_json::from_value(json!({ "pattern": "x" })).expect("a pattern is enough"); + assert!(!search.ignore_case); + assert!(search.include_glob.is_empty()); + assert_eq!(search.max_matches, None); + + let delete: DeleteInput = + serde_json::from_value(json!({ "path": "a" })).expect("a path is enough"); + assert!(!delete.recursive, "a recursive delete has to be asked for"); + + let _: EmptyInput = serde_json::from_value(json!({})).expect("{} is the whole request"); + } + + /// Neither guard, one guard, or both: the four shapes `editor::save` + /// branches on all have to survive the wire, including the legacy caller + /// that has never heard of a version. + #[test] + fn a_save_carries_whichever_guard_the_caller_holds() { + let bare: SaveInput = serde_json::from_value(json!({ "path": "a.rs", "content": "x" })) + .expect("an unguarded overwrite is a legal request"); + assert!(bare.expected_mtime.is_none() && bare.expected_version.is_none()); + + let legacy: SaveInput = + serde_json::from_value(json!({ "path": "a.rs", "content": "x", "expected_mtime": 7 })) + .expect("the pre-version request shape still parses"); + assert_eq!(legacy.expected_mtime, Some(7)); + assert!(legacy.expected_version.is_none()); + + let versioned: SaveInput = serde_json::from_value( + json!({ "path": "a.rs", "content": "x", "expected_version": "b-000000000000000a" }), + ) + .expect("a version-only request parses"); + assert_eq!( + versioned.expected_version.as_deref(), + Some("b-000000000000000a") + ); + assert!(versioned.expected_mtime.is_none()); + + let both: SaveInput = serde_json::from_value(json!({ + "path": "a.rs", "content": "x", "expected_mtime": 7, "expected_version": "v" + })) + .expect("sending both is legal; the version decides"); + assert_eq!(both.expected_mtime, Some(7)); + assert_eq!(both.expected_version.as_deref(), Some("v")); + } + + /// The enum spellings are the wire. An agent sends these strings, so a + /// rename breaks every stored call without touching a single signature. + #[test] + fn enum_variants_keep_their_wire_spellings() { + for (text, variant) in [ + ("worktree", Against::Worktree), + ("index", Against::Index), + ("head", Against::Head), + ("upstream", Against::Upstream), + ] { + assert_eq!( + serde_json::to_value(variant).expect("serializes"), + json!(text) + ); + let parsed: Against = serde_json::from_value(json!(text)).expect("a known comparison"); + assert_eq!( + serde_json::to_value(parsed).expect("serializes"), + json!(text) + ); + assert_eq!( + variant.as_str(), + text, + "as_str drifted from the serialized name" + ); + } + for (text, variant) in [ + ("fetch", SyncAction::Fetch), + ("pull", SyncAction::Pull), + ("push", SyncAction::Push), + ] { + assert_eq!( + serde_json::to_value(variant).expect("serializes"), + json!(text) + ); + let parsed: SyncAction = serde_json::from_value(json!(text)).expect("a known action"); + assert_eq!( + serde_json::to_value(parsed).expect("serializes"), + json!(text) + ); + } + for (text, variant) in [("push", StashAction::Push), ("pop", StashAction::Pop)] { + assert_eq!( + serde_json::to_value(variant).expect("serializes"), + json!(text) + ); + let parsed: StashAction = serde_json::from_value(json!(text)).expect("a known action"); + assert_eq!( + serde_json::to_value(parsed).expect("serializes"), + json!(text) + ); + } + for (text, variant) in [("file", EntryKind::File), ("folder", EntryKind::Folder)] { + assert_eq!( + serde_json::to_value(variant).expect("serializes"), + json!(text) + ); + let parsed: EntryKind = serde_json::from_value(json!(text)).expect("a known kind"); + assert_eq!( + serde_json::to_value(parsed).expect("serializes"), + json!(text) + ); + } + } + + /// A spelling this worker does not know must be refused, not defaulted: + /// `editor::git::sync` quietly fetching because it could not read `pusj` + /// would be a silent no-op where the caller asked to publish work. + #[test] + fn an_unknown_enum_spelling_is_refused() { + assert!(serde_json::from_value::(json!("pusj")).is_err()); + assert!(serde_json::from_value::(json!("HEAD")).is_err()); + assert!(serde_json::from_value::(json!("drop")).is_err()); + assert!(serde_json::from_value::(json!("directory")).is_err()); + } + + /// A required field is required. A save with no content must be refused at + /// the door rather than written as an empty file. + #[test] + fn a_request_missing_a_required_field_is_refused() { + assert!(serde_json::from_value::(json!({ "path": "a.rs" })).is_err()); + assert!(serde_json::from_value::(json!({ "from": "a" })).is_err()); + assert!(serde_json::from_value::(json!({})).is_err()); + assert!(serde_json::from_value::(json!({})).is_err()); + assert!(serde_json::from_value::(json!({ "rev": "HEAD" })).is_err()); + } +} + // ---------------------------------------------------------------- editor::find #[derive(Debug, Deserialize, JsonSchema)] diff --git a/editor/src/fuzzy.rs b/editor/src/fuzzy.rs index 350c3eb1c..14f5dc3bc 100644 --- a/editor/src/fuzzy.rs +++ b/editor/src/fuzzy.rs @@ -169,20 +169,80 @@ mod tests { assert_eq!(ranked[0].0, "src/editor.rs"); } + /// Ranking the same slice twice proves nothing — `rank` is pure, so the + /// two runs cannot differ. What is worth pinning is the documented + /// tie-break itself: shorter path first, then alphabetically, and never + /// dependent on the order the candidates arrived in. #[test] - fn ranking_is_stable_for_equal_scores() { - let candidates = ["b/x.rs", "a/x.rs"]; - let first = rank("x", &candidates, 10); - let second = rank("x", &candidates, 10); + fn equal_scores_break_on_length_then_alphabetically() { + // An empty query scores every candidate 0, which is the only way to get + // an exact tie across paths of different lengths. + let ordered: Vec<&str> = rank("", &["bbb.rs", "cc.rs", "a.rs"], 10) + .iter() + .map(|r| r.0) + .collect(); + assert_eq!(ordered, vec!["a.rs", "cc.rs", "bbb.rs"]); + + let shuffled: Vec<&str> = rank("", &["a.rs", "bbb.rs", "cc.rs"], 10) + .iter() + .map(|r| r.0) + .collect(); + assert_eq!( + ordered, shuffled, + "a picker that reshuffles equal rows by input order is unusable" + ); + } + + #[test] + fn equal_length_ties_break_alphabetically() { + let ranked = rank("x", &["b/x.rs", "a/x.rs"], 10); assert_eq!( - first.iter().map(|r| r.0).collect::>(), - second.iter().map(|r| r.0).collect::>() + ranked[0].1.score, ranked[1].1.score, + "the fixture only exercises the tie-break if it is actually a tie" + ); + assert_eq!( + ranked.iter().map(|r| r.0).collect::>(), + vec!["a/x.rs", "b/x.rs"] ); } + #[test] + fn a_candidate_that_does_not_match_is_dropped_from_the_ranking() { + let ranked = rank("mod", &["src/mod.rs", "README"], 10); + assert_eq!( + ranked.len(), + 1, + "a non-subsequence must not be ranked at all" + ); + assert_eq!(ranked[0].0, "src/mod.rs"); + } + + /// `positions` are byte offsets a UI slices the path with. An index that is + /// not a char boundary panics the consumer, so a path with multibyte + /// characters has to come back with boundaries. + #[test] + fn positions_are_char_boundaries_on_a_multibyte_path() { + let path = "src/café/main.rs"; + let m = score("cm", path).expect("subsequence"); + for &i in &m.positions { + assert!(path.is_char_boundary(i), "byte {i} splits a codepoint"); + } + let matched: String = m + .positions + .iter() + .map(|&i| path[i..].chars().next().expect("a character")) + .collect(); + assert_eq!(matched, "cm"); + } + #[test] fn limit_is_respected() { let candidates = ["a.rs", "ab.rs", "abc.rs", "abcd.rs"]; assert_eq!(rank("a", &candidates, 2).len(), 2); } + + #[test] + fn a_limit_past_the_end_of_the_list_is_not_an_error() { + assert_eq!(rank("a", &["a.rs"], 100).len(), 1); + } } diff --git a/editor/src/git.rs b/editor/src/git.rs index 8d1496968..799638705 100644 --- a/editor/src/git.rs +++ b/editor/src/git.rs @@ -292,6 +292,78 @@ mod tests { assert_eq!(r.entries[0].index, "renamed"); } + /// A copy is the other half of the `2 ` line type, and it carries a source + /// path exactly like a rename does. + #[test] + fn a_copy_carries_its_source_path() { + let out = "2 C. N... 100644 100644 100644 aaa bbb C75 copy/of.rs\toriginal.rs\n"; + let r = parse_status(out); + assert_eq!(r.entries[0].path, "copy/of.rs"); + assert_eq!(r.entries[0].index, "copied"); + assert_eq!(r.entries[0].renamed_from.as_deref(), Some("original.rs")); + assert!(r.entries[0].staged); + } + + /// The path pair is tab-separated precisely so a filename containing + /// spaces survives on either side of it. + #[test] + fn a_rename_with_spaces_on_both_sides_survives() { + let out = + "2 R. N... 100644 100644 100644 aaa bbb R100 new dir/new name.md\told dir/old name.md\n"; + let r = parse_status(out); + assert_eq!(r.entries[0].path, "new dir/new name.md"); + assert_eq!( + r.entries[0].renamed_from.as_deref(), + Some("old dir/old name.md") + ); + } + + /// The unmerged path is the tenth field, so it is the remainder of the + /// line. A split that stopped counting one field early cut this in half — + /// and, at the index the original code used, dropped the entry entirely. + #[test] + fn an_unmerged_path_with_spaces_is_not_split() { + let out = "u UU N... 100644 100644 100644 100644 aaa bbb ccc my docs/notes v2.md\n"; + let r = parse_status(out); + assert_eq!(r.entries.len(), 1, "an unmerged entry must not be dropped"); + assert_eq!(r.entries[0].path, "my docs/notes v2.md"); + assert_eq!(r.entries[0].index, "conflicted"); + } + + /// Every unmerged sub-state git can report reads as one conflict, and none + /// of them may go missing. + #[test] + fn every_unmerged_sub_state_is_reported() { + for xy in ["DD", "AU", "UD", "UA", "DU", "AA", "UU"] { + let line = + format!("u {xy} N... 100644 100644 100644 100644 aaa bbb ccc src/conflict.rs\n"); + let r = parse_status(&line); + assert_eq!(r.entries.len(), 1, "{xy} produced no entry"); + assert_eq!(r.entries[0].index, "conflicted", "{xy}"); + assert!(!r.clean, "{xy} is not a clean tree"); + } + } + + /// Every status letter an ordinary line can carry, plus the fallback for + /// one this worker has not been taught. + #[test] + fn every_status_letter_maps_to_a_word() { + for (xy, index, worktree) in [ + ("A.", "added", "unchanged"), + ("D.", "deleted", "unchanged"), + (".D", "unchanged", "deleted"), + ("MM", "modified", "modified"), + (".T", "unchanged", "typechange"), + ("Z.", "unknown", "unchanged"), + ] { + let line = format!("1 {xy} N... 100644 100644 100644 aaa bbb f.rs\n"); + let r = parse_status(&line); + assert_eq!(r.entries.len(), 1, "{xy} produced no entry"); + assert_eq!(r.entries[0].index, index, "{xy} index half"); + assert_eq!(r.entries[0].worktree, worktree, "{xy} worktree half"); + } + } + #[test] fn untracked_and_ignored_are_distinguished() { let r = parse_status("? new.rs\n! target/\n"); @@ -324,6 +396,29 @@ mod tests { assert_eq!(r.entries[0].path, "real.rs"); } + /// git said nothing at all — a repository with no changes, or a directory + /// that is not one. Either way an empty report, never a parse failure. + #[test] + fn empty_output_is_an_empty_clean_report() { + let r = parse_status(""); + assert!(r.entries.is_empty()); + assert!(r.branch.is_none()); + assert!(r.upstream.is_none()); + assert_eq!((r.ahead, r.behind), (0, 0)); + assert!(r.clean); + } + + /// A branch with no upstream configured: no `# branch.upstream` and no + /// `# branch.ab` line at all, which must read as "not tracking" rather than + /// as "in sync with something". + #[test] + fn a_branch_without_an_upstream_reports_none_and_zero() { + let r = parse_status("# branch.oid abc\n# branch.head feature/x\n"); + assert_eq!(r.branch.as_deref(), Some("feature/x")); + assert!(r.upstream.is_none()); + assert_eq!((r.ahead, r.behind), (0, 0)); + } + #[test] fn hunk_headers_parse_with_and_without_counts() { let d = "@@ -1,3 +1,4 @@\n context\n+added\n@@ -20 +21,2 @@\n-gone\n+new\n+more\n"; @@ -346,4 +441,69 @@ mod tests { assert_eq!(hunks.len(), 1); assert_eq!((hunks[0].added, hunks[0].removed), (1, 1)); } + + /// git appends the enclosing function after the closing `@@`. The range + /// parser has to stop there rather than try to read it as a third range. + #[test] + fn a_hunk_header_with_a_function_context_still_parses() { + let d = "@@ -10,2 +10,3 @@ fn main() {\n context\n+added\n"; + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 1); + assert_eq!((hunks[0].old_start, hunks[0].old_lines), (10, 2)); + assert_eq!((hunks[0].new_start, hunks[0].new_lines), (10, 3)); + assert_eq!((hunks[0].added, hunks[0].removed), (1, 0)); + } + + /// A diff spanning several files: the second file's own `---`/`+++` header + /// falls inside the first file's open hunk, and counting it would report a + /// phantom edit on every file but the last. + #[test] + fn a_multi_file_diff_keeps_its_hunks_separate() { + let d = concat!( + "diff --git a/one.rs b/one.rs\n", + "--- a/one.rs\n+++ b/one.rs\n", + "@@ -1 +1 @@\n-a\n+b\n", + "diff --git a/two.rs b/two.rs\n", + "--- a/two.rs\n+++ b/two.rs\n", + "@@ -5,0 +6,2 @@\n+x\n+y\n", + ); + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 2); + assert_eq!((hunks[0].added, hunks[0].removed), (1, 1)); + assert_eq!((hunks[1].added, hunks[1].removed), (2, 0)); + assert_eq!( + (hunks[1].old_start, hunks[1].old_lines), + (5, 0), + "a pure insertion spans no lines on the before side" + ); + } + + /// The no-newline marker is neither an addition nor a removal. Counting it + /// would inflate the totals of every file that lacks a trailing newline. + #[test] + fn the_no_newline_marker_is_not_an_edit() { + let d = "@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+b\n\\ No newline at end of file\n"; + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 1); + assert_eq!((hunks[0].added, hunks[0].removed), (1, 1)); + } + + /// One header this parser cannot read must cost that hunk and nothing + /// else — the same rule the status parser follows. + #[test] + fn a_malformed_hunk_header_does_not_discard_the_hunks_around_it() { + let d = "@@ -1 +1 @@\n-a\n+b\n@@ nonsense @@\n@@ -9,1 +9,1 @@\n-c\n+d\n"; + let hunks = parse_hunk_headers(d); + assert_eq!(hunks.len(), 2, "the readable hunks survive the bad header"); + assert_eq!(hunks[0].old_start, 1); + assert_eq!(hunks[1].old_start, 9); + } + + /// An unchanged file, or a path git does not track: no output, no hunks, + /// and `editor::git::hunks` reads that emptiness as its untracked probe. + #[test] + fn no_diff_output_means_no_hunks() { + assert!(parse_hunk_headers("").is_empty()); + assert!(parse_hunk_headers("diff --git a/x b/x\nsimilarity index 100%\n").is_empty()); + } } diff --git a/editor/src/lang.rs b/editor/src/lang.rs index eea29f88e..3390de547 100644 --- a/editor/src/lang.rs +++ b/editor/src/lang.rs @@ -90,6 +90,48 @@ mod tests { assert_eq!(for_path(".gitignore"), "plaintext"); } + /// The whole extensionless table, and each entry under a folder — the + /// filename lookup runs on the last segment, not on the path. + #[test] + fn every_whole_filename_resolves_bare_and_nested() { + for (name, expected) in [ + ("Dockerfile", "dockerfile"), + ("Containerfile", "dockerfile"), + ("Makefile", "makefile"), + ("makefile", "makefile"), + ("GNUmakefile", "makefile"), + ("CMakeLists.txt", "cmake"), + ("go.mod", "go"), + ("go.sum", "go"), + ("Cargo.lock", "toml"), + ] { + assert_eq!(for_path(name), expected, "{name}"); + assert_eq!( + for_path(&format!("nested/dir/{name}")), + expected, + "{name} under a folder" + ); + } + } + + /// A backslash separates path segments too, so a Windows-shaped path has + /// to resolve on its filename rather than on the whole string. + #[test] + fn a_backslash_separated_path_resolves_on_its_filename() { + assert_eq!(for_path(r"src\main.rs"), "rust"); + assert_eq!(for_path(r"docker\Dockerfile"), "dockerfile"); + } + + /// The dotfile prefixes match on a prefix, so their suffixed variants have + /// to land in the same place rather than falling through to an extension. + #[test] + fn suffixed_dotfiles_follow_their_prefix() { + assert_eq!(for_path(".envrc"), "shell"); + assert_eq!(for_path(".env.production"), "shell"); + assert_eq!(for_path(".gitignore.local"), "plaintext"); + assert_eq!(for_path(".dockerignore"), "plaintext"); + } + #[test] fn unknown_falls_back_to_plaintext() { assert_eq!(for_path("data.qqq"), "plaintext"); diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 6e50f2eba..9a01b24f2 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -1,10 +1,11 @@ //! Editor surface for code, built on top of the workers that already exist. //! //! `editor` opens no files and spawns no processes. Reads, writes and git all -//! go through `shell`, which owns the filesystem jail; the console page is the -//! shared `@iii-dev/console-ui` component set. What this worker adds is the -//! part neither of those has: a diff, a set of git marks, a ranked file -//! search, and a save that refuses to clobber. +//! go through `shell`, which owns the filesystem jail; the console page edits +//! in the shared `@iii-dev/console-ui` Monaco and renders files and diffs with +//! `@pierre/diffs`. What this worker adds is the part none of those has: a +//! diff, a set of git marks, a ranked file search, and a save that refuses to +//! clobber. pub mod bus; pub mod config; diff --git a/editor/src/observe.rs b/editor/src/observe.rs index 66ebbce9b..daf22f950 100644 --- a/editor/src/observe.rs +++ b/editor/src/observe.rs @@ -132,6 +132,13 @@ pub fn relative(path: &str, root: &str) -> String { } let trimmed = root.trim_end_matches('/'); path.strip_prefix(trimmed) + // Whole segments only. A raw `strip_prefix` treats the root as a string + // rather than a path, so root `/srv/app` turned `/srv/application/a.rs` + // into `lication/a.rs` — a plausible-looking path pointing nowhere, + // reported as the file that changed. This is the same invariant + // `Session::remap` is built on, for the same reason: a prefix that ends + // mid-segment is not a parent. + .filter(|rest| rest.is_empty() || rest.starts_with('/')) .map(|rest| rest.trim_start_matches('/')) .filter(|rest| !rest.is_empty()) .unwrap_or(path) @@ -326,6 +333,55 @@ mod tests { assert_eq!(t.kind, "moved"); } + /// The whole write whitelist in one place. A verb dropped from the table + /// stops producing events silently, which is exactly the blindness this + /// module exists to fix — and only three of the eight were pinned. + #[test] + fn every_watched_verb_maps_to_its_kind() { + for (id, kind) in [ + ("shell::fs::write", "modified"), + ("shell::fs::sed", "modified"), + ("coder::update-file", "modified"), + ("coder::create-file", "created"), + ("shell::fs::rm", "deleted"), + ("coder::delete-file", "deleted"), + ("shell::fs::mv", "moved"), + ("coder::move", "moved"), + ] { + let t = touched(&call(id, json!({ "path": "a.rs" }))) + .unwrap_or_else(|| panic!("{id} produced no touch")); + assert_eq!(t.kind, kind, "{id} reported the wrong kind"); + assert_eq!(t.path, "a.rs", "{id} lost its path"); + } + } + + /// A move carries both ends. The destination is the one a surface opens, + /// so it has to win even when the source is present under `path`. + #[test] + fn a_destination_outranks_the_source_path() { + let t = touched(&call( + "coder::move", + json!({ "path": "old.rs", "dst": "new.rs" }), + )) + .unwrap(); + assert_eq!(t.path, "new.rs"); + } + + #[test] + fn a_batch_move_reports_its_destination() { + let t = touched(&call( + "coder::move", + json!({ "files": [{ "dst": "new.rs" }] }), + )) + .unwrap(); + assert_eq!(t.path, "new.rs"); + } + + #[test] + fn an_empty_batch_is_skipped() { + assert!(touched(&call("coder::create-file", json!({ "files": [] }))).is_none()); + } + #[test] fn a_batch_shape_reports_its_first_path() { let t = touched(&call( @@ -376,11 +432,52 @@ mod tests { assert_eq!(relative("/elsewhere/a.rs", "/srv/app"), "/elsewhere/a.rs"); } + /// A sibling whose name merely starts with the root's is not inside it. + /// Stripping by string rather than by segment turned + /// `/srv/application/a.rs` into `lication/a.rs`: a path that looks real, + /// resolves nowhere, and would be reported as the file that changed. + #[test] + fn a_sibling_sharing_the_roots_prefix_is_not_inside_it() { + assert_eq!( + relative("/srv/application/a.rs", "/srv/app"), + "/srv/application/a.rs" + ); + assert_eq!(relative("/srv/app-2/a.rs", "/srv/app"), "/srv/app-2/a.rs"); + assert_eq!(relative("/srv/appendix", "/srv/app"), "/srv/appendix"); + // The genuine child still resolves, so the guard did not overshoot. + assert_eq!(relative("/srv/app/a.rs", "/srv/app"), "a.rs"); + } + + /// The harness stamps `fs_scope` with whatever the session had; anything + /// that is not a string root is no root at all, and the observer falls back + /// to the workspace's own. + #[test] + fn a_root_that_is_not_a_string_is_not_a_session_root() { + assert!(session_root(Some(&json!({ "fs_scope": { "root": 7 } }))).is_none()); + assert!(session_root(Some(&json!({ "fs_scope": {} }))).is_none()); + assert!(session_root(Some(&json!({ "fs_scope": null }))).is_none()); + } + #[test] fn a_dot_root_leaves_the_path_alone() { assert_eq!(relative("src/a.rs", "."), "src/a.rs"); } + /// No root stamped and none stored: the path is already the best answer. + #[test] + fn an_empty_root_leaves_the_path_alone() { + assert_eq!(relative("src/a.rs", ""), "src/a.rs"); + assert_eq!(relative("/srv/app/a.rs", ""), "/srv/app/a.rs"); + } + + /// A write reported against the root itself must not collapse to an empty + /// path, which no surface can render and no read can resolve. + #[test] + fn a_path_equal_to_the_root_is_left_alone() { + assert_eq!(relative("/srv/app", "/srv/app"), "/srv/app"); + assert_eq!(relative("/srv/app/", "/srv/app"), "/srv/app/"); + } + #[test] fn the_hook_always_continues() { assert_eq!(HookOutput::default().decision, "continue"); diff --git a/editor/src/tree.rs b/editor/src/tree.rs index 92ced617c..324252f6b 100644 --- a/editor/src/tree.rs +++ b/editor/src/tree.rs @@ -177,6 +177,15 @@ mod tests { } } + /// Nodes below `node`, i.e. exactly what the walk spends a visit on — a + /// directory costs one just like a file does. + fn count_visits(node: &Value) -> usize { + match node.get("children").and_then(Value::as_array) { + Some(children) => children.iter().map(|c| 1 + count_visits(c)).sum(), + None => 0, + } + } + #[test] fn paths_are_joined_from_the_root_down() { let walked = walk(&sample(), 100); @@ -196,12 +205,20 @@ mod tests { /// The visit budget must not cost a real tree any of its files. #[test] fn a_repository_shaped_tree_comes_back_whole() { - // 3 levels × 4 folders each = 84 directories, 5 files in every one: - // 425 files, ~509 visits. Well past MIN_VISIT_BUDGET's reach at a small - // limit, and nowhere near the budget a real `editor::find` call gets. - let tree = repository_shaped(3, 4, 5); - let total = count_files(tree.get("root").expect("a root")); + // 4 levels × 4 folders each = 340 directories, 5 files in every one: + // 1,705 files and 2,045 visits. The visit count is the load-bearing + // number — a fixture that fits inside MIN_VISIT_BUDGET would come back + // whole however badly the budget regressed, so it is asserted below. + let tree = repository_shaped(4, 4, 5); + let root = tree.get("root").expect("a root"); + let total = count_files(root); + let visits = count_visits(root); assert!(total > 400, "fixture is meant to be repository-sized"); + assert!( + visits > MIN_VISIT_BUDGET, + "a fixture needing only {visits} visits sits inside the floor budget, so it \ + could not fail this test even if the budget stopped scaling with the limit" + ); // The shipped `max_find_candidates`, i.e. what `editor::find` passes. let walked = walk(&tree, 50_000); @@ -216,6 +233,33 @@ mod tests { ); } + /// The budget is eight visits per file asked for. Both sides of that + /// multiplier matter: too tight and an ordinary listing comes back short, + /// too loose and a directory-only tree is walked without bound. + #[test] + fn the_visit_budget_scales_with_the_limit() { + // 4,200 empty folders, then one file: 4,201 visits to reach it, which + // is past MIN_VISIT_BUDGET so only the scaled budget can decide. + let mut children: Vec = (0..4_200) + .map(|i| json!({ "name": format!("d{i}"), "kind": "dir", "children": [] })) + .collect(); + children.push(json!({ "name": "last.rs", "kind": "file" })); + let tree = json!({ "root": { "name": "r", "kind": "dir", "children": children } }); + + // 500 × 8 = 4,000 visits: stops short of the file. + let tight = walk(&tree, 500); + assert!( + tight.paths.is_empty(), + "4,000 visits cannot reach node 4,201" + ); + assert!(tight.truncated, "a walk cut short must not look complete"); + + // 1,000 × 8 = 8,000 visits: comfortably past it. + let roomy = walk(&tree, 1_000); + assert_eq!(roomy.paths, vec!["last.rs"]); + assert!(!roomy.truncated); + } + /// Pins shell's real vocabulary. Reading `dir` as a file is what made /// the tree open directories instead of expanding them. #[test] @@ -232,6 +276,42 @@ mod tests { assert_eq!(walk(&tree, 10).paths, vec!["src/a.rs"]); } + /// `folder` is the spelling accepted alongside shell's own `dir`, so a + /// rename on that side degrades to a wrong-looking tree rather than to + /// directories being listed as openable files. + #[test] + fn a_folder_spelled_node_is_descended_too() { + let tree = json!({ + "root": { + "name": "r", "kind": "dir", + "children": [{ + "name": "src", "kind": "folder", + "children": [{ "name": "a.rs", "kind": "file" }] + }] + } + }); + assert_eq!(walk(&tree, 10).paths, vec!["src/a.rs"]); + } + + /// A node carrying children is a directory whatever it calls itself. This + /// is the fallback that keeps an unrecognised `kind` from being emitted as + /// a file the picker would then try to open. + #[test] + fn a_node_with_children_and_an_unknown_kind_is_still_a_directory() { + let tree = json!({ + "root": { + "name": "r", "kind": "dir", + "children": [{ + "name": "src", "kind": "something-new", + "children": [{ "name": "a.rs", "kind": "file" }] + }] + } + }); + let walked = walk(&tree, 10); + assert_eq!(walked.paths, vec!["src/a.rs"]); + assert!(!walked.paths.iter().any(|p| p == "src")); + } + #[test] fn an_empty_dir_contributes_nothing() { let tree = json!({ diff --git a/editor/src/workspace.rs b/editor/src/workspace.rs index 1deb4b066..56fd63f73 100644 --- a/editor/src/workspace.rs +++ b/editor/src/workspace.rs @@ -200,6 +200,19 @@ mod tests { assert_eq!(s.expanded, vec!["tests".to_string()]); } + /// Collapsing takes descendants with it, and only descendants. `src/app` + /// must not collapse `src/application` — the same whole-segment rule the + /// remap has, and the same silent wrongness when it is missing. + #[test] + fn collapse_matches_whole_segments_only() { + let mut s = Session::default(); + s.expand("src/app"); + s.expand("src/app/deep"); + s.expand("src/application"); + s.collapse("src/app"); + assert_eq!(s.expanded, vec!["src/application".to_string()]); + } + #[test] fn expand_is_idempotent() { let mut s = Session::default(); @@ -208,6 +221,26 @@ mod tests { assert_eq!(s.expanded.len(), 1); } + /// Re-opening a file must not move its tab: a surface renders buffers in + /// this order, so an upsert that pushed the re-read file to the end would + /// shuffle the tab bar on every save. + #[test] + fn upsert_replaces_in_place_and_keeps_the_order() { + let mut s = Session::default(); + s.upsert(buf("a.rs")); + s.upsert(buf("b.rs")); + let mut again = buf("a.rs"); + again.mtime = 42; + s.upsert(again); + + let paths: Vec<&str> = s.buffers.iter().map(|b| b.path.as_str()).collect(); + assert_eq!(paths, vec!["a.rs", "b.rs"]); + assert_eq!( + s.buffers[0].mtime, 42, + "the newer read replaced the older one" + ); + } + #[test] fn moving_a_file_remaps_its_buffer() { let mut s = Session::default(); @@ -225,7 +258,11 @@ mod tests { s.expand("src/app"); s.expand("src/app/deep"); - s.remap("src/app", "src/ui"); + assert_eq!( + s.remap("src/app", "src/ui"), + 4, + "two buffers and two expanded folders were rewritten" + ); let paths: Vec<&str> = s.buffers.iter().map(|b| b.path.as_str()).collect(); assert_eq!(paths, vec!["src/ui/a.rs", "src/ui/deep/b.rs", "other.rs"]); @@ -234,13 +271,22 @@ mod tests { #[test] fn remap_matches_whole_segments_only() { - // `src/app` must not swallow `src/application.rs`. + // `src/app` must not swallow `src/application.rs`, in either list: a + // plain `starts_with` rewrites both into nonsense. let mut s = Session::default(); s.upsert(buf("src/application.rs")); s.upsert(buf("src/app/x.rs")); - s.remap("src/app", "src/ui"); + s.expand("src/application"); + s.expand("src/app"); + + assert_eq!( + s.remap("src/app", "src/ui"), + 2, + "only the buffer and the folder actually under src/app move" + ); let paths: Vec<&str> = s.buffers.iter().map(|b| b.path.as_str()).collect(); assert_eq!(paths, vec!["src/application.rs", "src/ui/x.rs"]); + assert_eq!(s.expanded, vec!["src/application", "src/ui"]); } #[test] diff --git a/editor/tests/integration.rs b/editor/tests/integration.rs index c3422dca1..d6b56ff3b 100644 --- a/editor/tests/integration.rs +++ b/editor/tests/integration.rs @@ -96,8 +96,16 @@ fn no_single_wait_outlives_the_budget() { ); } +/// `None` when this machine cannot host an isolated engine, having said which +/// of the two reasons applies. Both messages live here rather than being split +/// with the caller: printing "iii is not on PATH" for a skip that was actually +/// "your rig is running" is how a permanently-skipped test gets read as passing +/// coverage. async fn boot() -> Option { - let iii_bin = which::which("iii").ok()?; + let Some(iii_bin) = which::which("iii").ok() else { + eprintln!("skipping: the `iii` binary is not on PATH"); + return None; + }; if engine_already_running() { eprintln!( @@ -186,8 +194,8 @@ async fn await_function( #[tokio::test] async fn diff_round_trips_over_the_bus() { + // `boot` reports which of the two skip conditions applied. let Some(_h) = boot().await else { - eprintln!("skipping: `iii` binary not on PATH"); return; }; diff --git a/iii-permissions.yaml b/iii-permissions.yaml index f78abbcb3..f579a6c45 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -87,6 +87,11 @@ rules: - '!session::on-config-change' # (coder::on-config-change retired: the code surface folded into the shell # worker, whose shell::on-config-change is already denied above.) + # editor's file-change observer: a harness post-trigger hook target, fired + # engine-side (bypassing this gate) after a shell/coder write. Calling it + # directly would let an agent forge an `editor::changed` event — path, cause + # and patch are taken from the payload — into every subscribed console tab. + - '!editor::on-file-change' # harness: deny-by-default for in-run agents (harness.md § Agent exposure). # send/run let a model start arbitrary turns outside any max_turns guard; # turn is the internal loop step; function::trigger/resolve forge call ids @@ -222,6 +227,7 @@ rules: - editor::buffers::list - editor::git::status - editor::git::hunks + - editor::git::show # Read-only code surface (coder::*, now served by the shell worker). # Mutating ops (create/update/move/delete-file) stay approval-gated. - coder::info