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..b0c6c8467 --- /dev/null +++ b/editor/Cargo.lock @@ -0,0 +1,2273 @@ +# 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", + "async-trait", + "clap", + "iii-console-ui", + "iii-helpers", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +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..57ed9286f --- /dev/null +++ b/editor/Cargo.toml @@ -0,0 +1,43 @@ +[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" } +# 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" +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"] } +# 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" + +[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..bd75a4e60 --- /dev/null +++ b/editor/README.md @@ -0,0 +1,216 @@ +# 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 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. + +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`](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. + +## 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`](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 + +```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 version 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. `expected_mtime` also works and is still + // honoured, but it cannot see a write that landed inside the same second. + let saved = call("editor::save", json!({ + "path": "src/main.rs", "content": edited, "expected_version": file["version"], + })).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 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. | +| `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. + +## 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 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. + +`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. + +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 + +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. The block below is what +gets seeded when nothing is stored yet, and `--config ` takes a file in +that shape as an optional one-time seed that never overwrites a stored value. +The committed `config.yaml` is *not* that file: it is a bare engine config +(`workers: []`) for running this worker from source, and it would be rejected as +a worker seed. + +If the configuration worker cannot be reached at boot, the worker retries, then +starts on the `--config` seed rather than exiting — with a warning naming which +numbers are in force — and keeps asking in the background until the +authoritative value lands. The built-in defaults are used only when nothing was +seeded either. + +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 # 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 +search_max_matches: 2000 # matching lines editor::search collects +git_timeout_ms: 15000 # per git invocation handed to shell::exec +``` + +## Local development & testing + +```bash +iii -c config.yaml # a bare engine (workers: []) so this worker runs from source +cargo run --release -- --url ws://127.0.0.1:49134 +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..f4ffa8d38 --- /dev/null +++ b/editor/skills/SKILL.md @@ -0,0 +1,195 @@ +--- +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 `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 + (`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, 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 + 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. + +## 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. + +## The two rules that prevent data loss + +**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`.** + +`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. + +## 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/bus.rs b/editor/src/bus.rs new file mode 100644 index 000000000..d3ce49717 --- /dev/null +++ b/editor/src/bus.rs @@ -0,0 +1,325 @@ +//! 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::atomic::{AtomicU64, Ordering}; +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 — all `shell::fs::stat` reports, and the + /// reason `editor::save`'s strong conflict guard versions content instead of + /// looking for a finer timestamp it cannot get. See + /// `functions::conflicted`. + 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, + /// 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: Arc) -> Self { + Self { + iii, + ws_url, + git_timeout_ms, + } + } + + fn git_timeout(&self) -> u64 { + self.git_timeout_ms.load(Ordering::Relaxed) + } + + 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(), + }); + if let Some(dir) = cwd { + payload["cwd"] = json!(dir); + } + let value = self + .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}"))) + } + + /// 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() + ))); + } + 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 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) + .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 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; + 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..b2a497597 --- /dev/null +++ b/editor/src/config.rs @@ -0,0 +1,268 @@ +//! 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. +//! +//! Nothing in this shape is committed: the configuration worker is the source of +//! truth after first boot, and these defaults are what gets seeded when nothing +//! is stored yet. The `config.yaml` next to this crate is an *engine* config for +//! local runs, not a seed — `deny_unknown_fields` below rejects it as one, which +//! `tests::an_engine_config_is_not_a_worker_seed` pins. + +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 `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, + + /// 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()); + } + + /// The committed `config.yaml` is an engine config, not a seed for this + /// worker, and handing it to `--config` is a mistake the README used to + /// teach. It fails loudly, which is the behaviour worth keeping. + #[test] + fn an_engine_config_is_not_a_worker_seed() { + assert!(WorkerConfig::from_yaml("workers: []").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 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 new file mode 100644 index 000000000..7b4d2954c --- /dev/null +++ b/editor/src/configuration.rs @@ -0,0 +1,436 @@ +//! 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::atomic::{AtomicU64, Ordering}; +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; +/// How many times the background recovery re-attempts the boot handshake after +/// a fallback. At the backoff below this covers roughly twenty minutes, which is +/// far longer than any worker start-order race. +const RECOVERY_ATTEMPTS: u32 = 60; + +/// Where a served configuration came from, for the boot log and for tests. +pub const SOURCE_SEED: &str = "the --config seed"; +pub const SOURCE_DEFAULTS: &str = "the built-in defaults"; + +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) +} + +/// What to serve when the boot handshake never completed. +/// +/// 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. +/// +/// Either way this is a *stopgap*: [`spawn_boot_recovery`] keeps trying for the +/// authoritative value, and the returned source string is what the boot log +/// prints so "running on the seed" is never mistaken for "running on live +/// config". +pub fn fallback_config(seed: Option<&WorkerConfig>) -> (WorkerConfig, &'static str) { + match seed { + Some(seed) => (seed.clone(), SOURCE_SEED), + None => (WorkerConfig::default(), SOURCE_DEFAULTS), + } +} + +/// Keep trying to complete the boot handshake after a fallback, and publish the +/// authoritative values the moment it lands. +/// +/// Without this the fallback would be permanent in its likeliest cause. When it +/// is `configuration::register` that never landed — the configuration worker was +/// not up yet — the configuration worker holds no `editor` entry, so nothing can +/// ever emit the `configuration:updated` event the hot-reload trigger waits on, +/// and the stopgap limits would be the limits for the life of the process. A +/// worker permanently stuck on a seed is worse than one that refused to start. +/// +/// It publishes through [`apply_config`], the same path the hot-reload handler +/// uses, so the git timeout the bus reads is republished too. +pub fn spawn_boot_recovery( + iii: Arc, + cell: ConfigCell, + git_timeout_ms: Arc, + seed: Option, +) { + tokio::spawn(async move { + for attempt in 1..=RECOVERY_ATTEMPTS { + tokio::time::sleep(recovery_backoff(attempt)).await; + if let Err(e) = register_config(&iii, seed.as_ref()).await { + tracing::debug!(attempt, error = %e, "configuration still not registrable"); + continue; + } + match fetch_config(&iii).await { + Ok(cfg) => { + apply_config(&cell, &git_timeout_ms, cfg).await; + tracing::warn!( + attempt, + "configuration handshake recovered; the editor is serving the \ + authoritative limits and no longer the boot fallback" + ); + return; + } + Err(e) => tracing::debug!(attempt, error = %e, "configuration still unreadable"), + } + } + tracing::error!( + attempts = RECOVERY_ATTEMPTS, + "the configuration worker never answered; the editor is serving its boot fallback \ + limits for the rest of this process. Restart it once the configuration worker is up." + ); + }); +} + +/// Linear, then capped: quick retries for a configuration worker that is merely +/// starting, then a slow poll that costs nothing while it stays down. +fn recovery_backoff(attempt: u32) -> Duration { + Duration::from_millis((500 * u64::from(attempt)).min(30_000)) +} + +/// 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, 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); +} + +/// 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()`. +/// +/// The bind is retried on the same shape as the boot handshake, and for the same +/// reason: a bus call that fails once against a busy engine is not a reason to +/// give up, because the caller's only other move is to exit and be restarted +/// straight back into the same race. Only a bind that never succeeds is an +/// error, and the caller decides what to do about it. +pub async 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, &timeout).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 })), + ); + + let mut last: Option = None; + for attempt in 1..=CONFIG_RETRIES { + match 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(_) => return Ok(()), + Err(e) => { + tracing::warn!( + attempt, + error = %e, + "could not bind the configuration trigger; retrying" + ); + last = Some(e); + if attempt < CONFIG_RETRIES { + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * attempt as u64, + )) + .await; + } + } + } + } + Err(last.unwrap_or_else(|| { + Error::Handler("binding the configuration trigger never succeeded".into()) + })) +} + +/// 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, git_timeout_ms: &AtomicU64) { + match fetch_config(iii).await { + Ok(cfg) => { + apply_config(cell, git_timeout_ms, cfg).await; + tracing::info!("editor configuration reloaded"); + } + Err(e) => tracing::error!( + error = %e, + "config-change: failed to fetch authoritative configuration; keeping previous config" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A boot that exhausted its retries must keep the operator's numbers. The + /// seed is `config.yaml`, so falling back to `WorkerConfig::default()` while + /// one exists would hand back every limit the operator had tightened. + #[test] + fn a_failed_boot_falls_back_to_the_seed_not_the_defaults() { + let seed = WorkerConfig { + max_file_bytes: 64_000, + git_timeout_ms: 3_000, + ..WorkerConfig::default() + }; + assert_ne!( + seed, + WorkerConfig::default(), + "the fixture has to differ from the baseline to prove anything" + ); + + let (cfg, source) = fallback_config(Some(&seed)); + assert_eq!(cfg, seed, "the seed's values, not the shipped ones"); + assert_eq!(cfg.max_file_bytes, 64_000); + assert_eq!(cfg.git_timeout_ms, 3_000); + assert_eq!(source, SOURCE_SEED); + } + + /// With nothing seeded, nothing was configured on this side, so the baseline + /// is the operator's choice by default. + #[test] + fn without_a_seed_the_fallback_is_the_baseline() { + let (cfg, source) = fallback_config(None); + assert_eq!(cfg, WorkerConfig::default()); + 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)); + assert_eq!(recovery_backoff(4), Duration::from_millis(2_000)); + assert_eq!( + recovery_backoff(RECOVERY_ATTEMPTS), + Duration::from_millis(30_000), + "a configuration worker that stays down must not be polled faster and faster" + ); + } +} diff --git a/editor/src/diff.rs b/editor/src/diff.rs new file mode 100644 index 000000000..3775509a7 --- /dev/null +++ b/editor/src/diff.rs @@ -0,0 +1,309 @@ +//! 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()); + } + + /// 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"; + 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/events.rs b/editor/src/events.rs new file mode 100644 index 000000000..112cc7166 --- /dev/null +++ b/editor/src/events.rs @@ -0,0 +1,329 @@ +//! `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::atomic::{AtomicBool, Ordering}; +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(()) + } +} + +/// A latch that is `true` the first time and never again — for a condition +/// worth one loud line and no more. +#[derive(Clone, Default)] +struct Once(Arc); + +impl Once { + fn first(&self) -> bool { + !self.0.swap(true, Ordering::Relaxed) + } +} + +/// Fans `editor::changed` out to every current subscriber. +#[derive(Clone)] +pub struct ChangedEmitter { + iii: Arc, + subscribers: SubscriberSet, + /// Whether the "this event went nowhere" line has already been said. + unsubscribed: Once, +} + +impl ChangedEmitter { + pub fn new(iii: Arc, subscribers: SubscriberSet) -> Self { + Self { + iii, + subscribers, + unsubscribed: Once::default(), + } + } + + 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() { + // Loud once, then quiet. This is the only place a failed trigger + // type registration is observable at all (see + // `register_changed_trigger`), and "nobody has bound it yet" and + // "nobody ever can" look identical from here — so it has to be + // visible without a debug filter, and it must not log a line per + // write for a workspace nobody is watching. + if self.unsubscribed.first() { + tracing::warn!( + path = %event.path, + trigger_type = CHANGED, + "editor::changed fired with nothing bound to it, so push updates are going \ + nowhere. Either no surface has subscribed yet, or the trigger type never \ + reached the engine." + ); + } else { + tracing::debug!(path = %event.path, "editor::changed: nobody subscribed, dropping"); + } + 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"); + } else { + tracing::info!( + function_id = %function_id, + path = %event.path, + kind = %event.kind, + added = event.added, + removed = event.removed, + "editor::changed delivered" + ); + } + } + } +} + +/// 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(); + // `register_trigger_type` hands back a typed handle, not a `Result`: the SDK + // queues the registration message and drops the send result on the floor, + // so there is nothing here to check and nothing to retry. The log therefore + // claims only what actually happened — the message was sent — because + // "registered" would be a claim this worker cannot make. + // + // The failure it cannot see is a real one: without the type, every + // `editor::changed` is undeliverable and the whole push surface is dead. The + // loud signal for that lives in `ChangedEmitter::emit`, which warns the + // first time an event has nowhere to go. + let _handle = 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, + "sent the trigger type registration; delivery is confirmed by the first subscription" + ); + 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); + } + + /// The undeliverable-event warning has to be said once, not once per write: + /// a workspace nobody is watching would otherwise log a line per edit. + #[test] + fn the_first_undeliverable_event_is_the_only_loud_one() { + let once = Once::default(); + assert!(once.first(), "the first drop is worth a warning"); + assert!(!once.first()); + assert!(!once.first()); + // The latch is shared, not copied: a cloned emitter must not get a + // second chance to shout. + assert!(!once.clone().first()); + } + + #[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 new file mode 100644 index 000000000..7482d909a --- /dev/null +++ b/editor/src/functions/mod.rs @@ -0,0 +1,1578 @@ +//! 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 and content version 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(); + + // Over the content actually returned, so the version and the + // bytes the caller holds always describe each other — a + // truncated read is versioned as the prefix it is. + let version = content_version(&file.content); + + // 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, + version: version.clone(), + 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, + version, + 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), + ); +} + +/// Opaque content version: byte length and a 64-bit FNV-1a digest of the bytes. +/// +/// Deliberately not a cryptographic hash. This answers "is this still the +/// content I read", a question whose worst outcome is a refused save, and it is +/// not an authorization decision — the filesystem boundary is `shell`'s jail. +/// Qualifying the digest with the length makes an accidental collision +/// implausible for the sizes an editor deals in, and computing it costs nothing +/// extra because `save` has already read the file to count the lines it +/// changed. No hashing dependency is added for it. +/// +/// The encoding is private: callers compare for equality and never parse it. +fn content_version(content: &str) -> String { + const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = FNV_OFFSET; + for byte in content.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + format!("{:x}-{:016x}", content.len(), hash) +} + +/// Whether the file on disk diverged from the copy this save started from. +/// +/// Two guards, and the caller picks which by the field it sends. +/// +/// `expected_version` is the strong one. An mtime cannot see a write that +/// landed inside the same second, and a same-second write is exactly how one +/// agent silently overwrites another. A nanosecond mtime would fix that too, +/// but this worker cannot get one: the timestamps it sees come from +/// `shell::fs::stat`, which reports whole seconds, and `editor` owns no +/// filesystem access to look closer. A content version also survives what a +/// timestamp does not — a clock stepping backwards, a checkout or a restore +/// rewriting mtimes, a filesystem with coarse granularity — and here it is the +/// cheaper of the two, because the read it needs has already happened. +/// +/// `expected_mtime` is the original guard, kept bit for bit: a caller that +/// knows nothing about versions behaves exactly as it did before. +fn conflicted( + expected_version: Option<&str>, + expected_mtime: Option, + disk_version: &str, + disk_mtime: i64, +) -> bool { + match (expected_version, expected_mtime) { + (Some(expected), _) => expected != disk_version, + (None, Some(expected)) => expected != disk_mtime, + // Neither guard armed: an overwrite the caller asked for. + (None, None) => false, + } +} + +/// 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 and what the content +/// version is taken over. 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(); + + // One read, shared by the guard and the line counts. Every save of an + // existing file already had to read it once; reading here rather than in + // each branch keeps it at once now that the guard needs the bytes too. + let on_disk = match existing.as_ref() { + Some(_) => Some(bus.read(&full, cfg.max_file_bytes).await?), + None => None, + }; + let disk_version = on_disk.as_ref().map(|f| content_version(&f.content)); + + if let (Some(stat), Some(disk), Some(version)) = + (existing.as_ref(), on_disk.as_ref(), disk_version.as_deref()) + { + if conflicted( + req.expected_version.as_deref(), + req.expected_mtime, + version, + stat.mtime, + ) { + let divergence = diff::diff( + &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, + version: version.to_string(), + disk_mtime: Some(stat.mtime), + disk_version: Some(version.to_string()), + conflict_patch: Some(divergence.patch), + added: divergence.added, + removed: divergence.removed, + created: false, + }); + } + } + + // A guard token 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() || req.expected_version.is_some()) && existing.is_none() { + return Err(Error::Handler(format!( + "{} no longer exists; re-open it before saving", + req.path + ))); + } + + let previous: Option = match on_disk.as_ref() { + Some(before) => { + 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?; + let version = content_version(&req.content); + + // The buffer's mtime and version are now stale everywhere else; refreshing + // them 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, + version: version.clone(), + 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, + version, + disk_mtime: None, + disk_version: 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 found = candidates(&bus, &root, &req, &cfg).await?; + let refs: Vec<&str> = found.paths.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: found.truncated, + from_git: found.from_git, + }) + } + }) + .description(DESC_FIND), + ); +} + +/// Paths to rank, where they came from, and whether they are all of them. +struct Candidates { + paths: Vec, + /// True when git's listing supplied them, so `.gitignore` was honoured. + from_git: bool, + /// True when the workspace held more paths than were collected — the + /// candidate cap, or the folder walk's visit budget. Either way the ranking + /// is over a prefix of the workspace and the response has to say so. + truncated: bool, +} + +/// 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 { + 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(Candidates { + truncated: paths.len() >= cfg.max_find_candidates, + paths, + from_git: true, + }); + } + + let listing = bus.tree(root, 12).await?; + let walked = tree::walk(&listing, cfg.max_find_candidates); + Ok(Candidates { + truncated: walked.truncated || walked.paths.len() >= cfg.max_find_candidates, + paths: walked.paths, + from_git: 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, + }; + // 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 => {} + 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); + + 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. + // `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 patch = truncate_on_boundary(out.stdout, 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"); + } + } + + #[test] + fn a_content_version_is_stable_and_content_addressed() { + assert_eq!( + content_version("fn main() {}"), + content_version("fn main() {}") + ); + assert_ne!(content_version("a\nb\n"), content_version("a\nB\n")); + // Same length, transposed bytes: the digest, not the length, has to + // carry this. + assert_ne!(content_version("ab"), content_version("ba")); + // Length is part of the token, so a prefix can never collide with the + // whole. + assert_ne!(content_version(""), content_version("\0")); + } + + /// The bug this guard exists for: a second write inside the same + /// filesystem second leaves `mtime` unchanged, so the mtime guard waves it + /// through and the save silently overwrites the other writer. + #[test] + fn a_same_second_write_is_caught_by_the_version_and_missed_by_the_mtime() { + let started_from = content_version("original\n"); + let now_on_disk = content_version("someone else's edit\n"); + let same_second = 1_700_000_000; + + assert!( + conflicted(Some(&started_from), None, &now_on_disk, same_second), + "the version guard must refuse a write over content it did not read" + ); + assert!( + !conflicted(None, Some(same_second), &now_on_disk, same_second), + "documents the mtime guard's blind spot: it cannot see this at all" + ); + // Both sent: the version decides, so a caller that sends the stale + // mtime alongside is still protected. + assert!(conflicted( + Some(&started_from), + Some(same_second), + &now_on_disk, + same_second + )); + } + + #[test] + fn a_matching_version_is_not_a_conflict() { + let v = content_version("unchanged\n"); + assert!(!conflicted(Some(&v), None, &v, 1_700_000_000)); + // An mtime that moved without the content changing is not a divergence + // for a version-guarded caller — a touch or a rewrite of identical + // bytes is nothing to refuse. + assert!(!conflicted(Some(&v), Some(1), &v, 999)); + } + + /// The legacy path, unchanged: mtime only, compared exactly as before. + #[test] + fn the_mtime_guard_still_applies_when_no_version_is_sent() { + let v = content_version("whatever\n"); + assert!(!conflicted(None, Some(42), &v, 42)); + assert!(conflicted(None, Some(41), &v, 42)); + } + + #[test] + fn no_guard_token_means_a_deliberate_overwrite() { + assert!(!conflicted( + None, + None, + &content_version("anything"), + 1_700_000_000 + )); + } + + /// 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] + 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); + } + // `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() || session != before { + 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 { + // 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. +/// +/// `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 + .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 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() { + // "é" 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"); + } + } + + /// 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 { + 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"); + } + + /// 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) { + 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..fdab00ca6 --- /dev/null +++ b/editor/src/functions/types.rs @@ -0,0 +1,745 @@ +//! Wire types for every registered function. +//! +//! Kept in one module because the functions share a small vocabulary (`Hunk`, +//! path, mtime, content version) and splitting them one file per function would +//! mean a copy of the same doc comments — what an mtime is for, what a version +//! is for — in every one of them. + +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, + /// 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 { + pub fn as_str(self) -> &'static str { + match self { + Against::Worktree => "worktree", + Against::Index => "index", + Against::Head => "head", + Against::Upstream => "upstream", + } + } +} + +#[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 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, +} + +#[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, +} + +#[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)] +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 not every path in the workspace was ranked: it held more than + /// `max_find_candidates`, or the folder walk stopped at its visit budget. + /// Either way this ranking is over a prefix of the workspace. + 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, + /// Opaque version of the `content` above. Pass it back as + /// `expected_version` on `editor::save` for a guard that does not depend on + /// the filesystem's timestamp resolution: it catches a write that landed + /// inside the same second, which `mtime` cannot. Compare it for equality + /// only — the encoding is this worker's business and may change. + pub version: String, + /// 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. + /// + /// Resolution is one second, which is all the filesystem reports. Two + /// writes inside the same second are therefore indistinguishable to this + /// field, and the second one wins silently — send `expected_version` + /// instead to close that window. + #[serde(default)] + pub expected_mtime: Option, + /// The `version` from the `editor::open` — or from the previous + /// `editor::save` — this edit started from. It is a version of the + /// *content*, so it catches a write that landed inside the same filesystem + /// second and it does not care whether a clock or a checkout moved the + /// mtime. + /// + /// When this is present it is the guard and `expected_mtime` is ignored. + /// When it is absent the `expected_mtime` comparison applies exactly as + /// before, so a caller that has never heard of a version is unaffected. + #[serde(default)] + pub expected_version: 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, + /// Version of the content this file now holds: what was written on success, + /// what is on disk on a conflict. Feed it into the next save as + /// `expected_version`. + pub version: String, + /// What was on disk when a conflict was detected. + pub disk_mtime: Option, + /// Version of the disk contents when a conflict was detected — the value + /// to send as `expected_version` once you have reconciled with them. + pub disk_version: 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..14f5dc3bc --- /dev/null +++ b/editor/src/fuzzy.rs @@ -0,0 +1,248 @@ +//! 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"); + } + + /// 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 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!( + 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 new file mode 100644 index 000000000..799638705 --- /dev/null +++ b/editor/src/git.rs @@ -0,0 +1,509 @@ +//! 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: `

` + // — 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(), + 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"); + } + + /// 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"); + 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 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"); + assert_eq!(r.entries.len(), 1); + 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"; + 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)); + } + + /// 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 new file mode 100644 index 000000000..3390de547 --- /dev/null +++ b/editor/src/lang.rs @@ -0,0 +1,140 @@ +//! 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"); + } + + /// 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"); + assert_eq!(for_path("noextension"), "plaintext"); + } +} diff --git a/editor/src/lib.rs b/editor/src/lib.rs new file mode 100644 index 000000000..9a01b24f2 --- /dev/null +++ b/editor/src/lib.rs @@ -0,0 +1,24 @@ +//! 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 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; +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; +pub mod workspace; diff --git a/editor/src/main.rs b/editor/src/main.rs new file mode 100644 index 000000000..46120d161 --- /dev/null +++ b/editor/src/main.rs @@ -0,0 +1,180 @@ +use anyhow::Result; +use clap::Parser; +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; + +#[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 where the authoritative limits live, and a + // loaded engine can answer its registration late. Dying on that timeout is + // worse than waiting: the source watcher restarts the worker straight back + // into the same race, so a slow boot becomes a crash loop that never + // converges. So: retry. + // + // And if the retries run out, still do not exit. Serve the seed — the + // operator's own numbers, from `--config` — and keep asking for the + // authoritative value in the background, because a worker that is up on + // known limits beats one that is not up at all. What is never done is + // guessing: the log says exactly which numbers are in force, and the + // built-in defaults are used only when nothing was seeded either. + const CONFIG_BOOT_ATTEMPTS: u32 = 5; + let mut loaded = None; + let mut last_err = None; + for attempt in 1..=CONFIG_BOOT_ATTEMPTS { + match configuration::register_config(&iii, seed.as_ref()).await { + Ok(()) => match configuration::fetch_config(&iii).await { + Ok(cfg) => { + loaded = Some(cfg); + break; + } + Err(e) => last_err = Some(format!("loading editor configuration: {e}")), + }, + Err(e) => last_err = Some(format!("registering editor configuration: {e}")), + } + if attempt < CONFIG_BOOT_ATTEMPTS { + let backoff = std::time::Duration::from_millis(400 * u64::from(attempt)); + tracing::warn!( + attempt, + backoff_ms = backoff.as_millis() as u64, + error = last_err.as_deref().unwrap_or("unknown"), + "editor configuration not ready; retrying" + ); + tokio::time::sleep(backoff).await; + } + } + let fell_back = loaded.is_none(); + let cfg = match loaded { + Some(cfg) => cfg, + None => { + let (cfg, source) = configuration::fallback_config(seed.as_ref()); + tracing::warn!( + attempts = CONFIG_BOOT_ATTEMPTS, + error = last_err.as_deref().unwrap_or("unknown"), + "the configuration worker never answered, so the editor is serving {source}; \ + these limits are a stopgap and may be stale until it does" + ); + cfg + } + }; + let git_timeout_ms = Arc::new(std::sync::atomic::AtomicU64::new(cfg.git_timeout_ms)); + let cfg = configuration::cell(cfg); + + // A fallback has to be temporary. The hot-reload trigger below only fires + // once the configuration worker holds an `editor` entry, and the likeliest + // reason the boot failed is that it was not up to take the registration — + // in which case no reload event can ever arrive. This keeps asking until it + // can, then publishes through the same path a reload would. + if fell_back { + configuration::spawn_boot_recovery( + iii.clone(), + cfg.clone(), + git_timeout_ms.clone(), + seed.clone(), + ); + } + + // 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.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. The bind retries + // internally, for the same reason the boot handshake does; a bind that never + // succeeds is fatal rather than a warning, because a worker whose limits can + // never be changed is worse than one that did not start. + configuration::register_config_trigger(&iii, cfg.clone(), git_timeout_ms) + .await + .map_err(|e| anyhow::anyhow!("binding the configuration trigger: {e}"))?; + + 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/observe.rs b/editor/src/observe.rs new file mode 100644 index 000000000..daf22f950 --- /dev/null +++ b/editor/src/observe.rs @@ -0,0 +1,485 @@ +//! 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, 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) + // 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) + .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() { + tracing::debug!("observer: no subscribers, skipping"); + } + if emitter.has_subscribers() { + let snapshot = cfg.read().await.clone(); + // 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()) + } + }) + .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 { + 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. + 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); + // `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 + // 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(&absolute, 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()), + }, + } + }; + + 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"); + } + + /// 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( + "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"); + } + + /// 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/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..324252f6b --- /dev/null +++ b/editor/src/tree.rs @@ -0,0 +1,394 @@ +//! 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; + +/// Smallest visit budget any walk gets, however small the file limit is. A +/// folder costs a visit but yields no path, so a low limit must still be able +/// to reach past the directories in front of the files. +const MIN_VISIT_BUDGET: usize = 1_000; + +/// A flattened snapshot, and whether the walk saw all of it. +#[derive(Debug, Default)] +pub struct Walk { + /// Every file path found, root-relative, in traversal order. + pub paths: Vec, + /// True when the walk stopped early — at `limit` files, or at the visit + /// budget — so a caller can report a partial listing as partial instead of + /// presenting it as the whole tree. + /// + /// A tree holding exactly `limit` files reports `true` as well: from in + /// here a listing cut at the boundary is indistinguishable from a complete + /// one, and claiming completeness wrongly is the worse error. + pub truncated: bool, +} + +/// Flatten a `coder::tree` snapshot to its file paths. +/// +/// Directories are descended but not emitted: this feeds the file picker, and a +/// folder is not something you open. `limit` bounds the files collected, and a +/// visit budget bounds the nodes walked to get them — without the second bound +/// a tree of empty folders is traversed in full however low `limit` is, since +/// directories never grow the result. +/// +/// The budget is deliberately generous (eight visits per file wanted, and never +/// under [`MIN_VISIT_BUDGET`]) because the cost of hitting it is a short +/// listing. `editor::find` calls this with `max_find_candidates`, so a real +/// checkout is nowhere near it; when it does bite, `truncated` says so. +pub fn walk(tree: &Value, limit: usize) -> Walk { + let mut state = Visit { + limit, + budget: limit.saturating_mul(8).max(MIN_VISIT_BUDGET), + walk: Walk::default(), + }; + if let Some(root) = tree.get("root") { + descend(root, "", &mut state); + } + state.walk +} + +struct Visit { + limit: usize, + budget: usize, + walk: Walk, +} + +impl Visit { + /// True when the walk must stop, recording that the result is partial. + fn spent(&mut self) -> bool { + if self.walk.paths.len() >= self.limit || self.budget == 0 { + self.walk.truncated = true; + return true; + } + false + } +} + +fn descend(node: &Value, prefix: &str, state: &mut Visit) { + if state.spent() { + return; + } + let Some(children) = node.get("children").and_then(Value::as_array) else { + return; + }; + for child in children { + if state.spent() { + return; + } + state.budget -= 1; + 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) { + descend(child, &path, state); + } 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. + state.walk.paths.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" }] + } + ] + } + ] + } + }) + } + + /// A directory tree shaped like a real checkout: nested a few levels deep, + /// dozens of folders, hundreds of files. `editor::find` walks with + /// `max_find_candidates`, so this is the case that must never come back + /// short — a bound that truncated an ordinary repository would be a worse + /// bug than the unbounded walk it replaced. + fn repository_shaped(depth: usize, dirs_per_level: usize, files_per_dir: usize) -> Value { + fn build(prefix: &str, level: usize, depth: usize, dirs: usize, files: usize) -> Value { + let mut children: Vec = (0..files) + .map(|f| json!({ "name": format!("{prefix}f{f}.rs"), "kind": "file" })) + .collect(); + if level < depth { + for d in 0..dirs { + let name = format!("{prefix}d{level}_{d}"); + let mut child = build(&name, level + 1, depth, dirs, files); + child["name"] = json!(name); + child["kind"] = json!("dir"); + children.push(child); + } + } + json!({ "children": children }) + } + let mut root = build("", 0, depth, dirs_per_level, files_per_dir); + root["name"] = json!("repo"); + root["kind"] = json!("dir"); + json!({ "root": root }) + } + + fn count_files(node: &Value) -> usize { + match node.get("children").and_then(Value::as_array) { + Some(children) => children.iter().map(count_files).sum(), + None => 1, + } + } + + /// 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); + assert_eq!( + walked.paths, + vec!["README.md", "src/main.rs", "src/app/mod.rs"] + ); + assert!(!walked.truncated, "three files is not a truncated walk"); + } + + #[test] + fn folders_are_descended_but_not_emitted() { + let paths = walk(&sample(), 100).paths; + assert!(!paths.iter().any(|p| p == "src" || p == "src/app")); + } + + /// The visit budget must not cost a real tree any of its files. + #[test] + fn a_repository_shaped_tree_comes_back_whole() { + // 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); + assert_eq!( + walked.paths.len(), + total, + "every file in an ordinary tree must be returned" + ); + assert!( + !walked.truncated, + "a complete walk must not claim to be partial" + ); + } + + /// 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] + 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!(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!({ + "root": { + "name": "r", "kind": "dir", + "children": [{ "name": "empty", "kind": "dir", "children": [] }] + } + }); + let walked = walk(&tree, 10); + assert!(walked.paths.is_empty()); + assert!(!walked.truncated, "an empty tree was walked in full"); + } + + /// A tree of empty directories must not be traversed without bound just + /// because it yields no files — and when the budget does stop it, the + /// result has to say so rather than pass a partial listing off as whole. + #[test] + fn the_visit_budget_stops_a_directory_only_tree_and_reports_it() { + // More empty folders than MIN_VISIT_BUDGET, with a file behind them + // that only an unbounded walk would ever reach. + let mut children: Vec = (0..MIN_VISIT_BUDGET + 200) + .map(|i| json!({ "name": format!("d{i}"), "kind": "dir", "children": [] })) + .collect(); + children.push(json!({ "name": "behind-the-budget.rs", "kind": "file" })); + let tree = json!({ "root": { "name": "r", "kind": "dir", "children": children } }); + + let walked = walk(&tree, 10); + assert!( + walked.paths.is_empty(), + "the walk must stop before the file the budget cannot reach" + ); + assert!( + walked.truncated, + "a walk cut short by the budget must not look complete" + ); + } + + /// The same shape inside the budget: the file is found, and nothing claims + /// to be truncated. This is the half that keeps the bound from quietly + /// shortening an ordinary listing. + #[test] + fn folders_within_the_budget_do_not_hide_the_files_behind_them() { + let mut children: Vec = (0..100) + .map(|i| json!({ "name": format!("d{i}"), "kind": "dir", "children": [] })) + .collect(); + children.push(json!({ "name": "after-the-folders.rs", "kind": "file" })); + let tree = json!({ "root": { "name": "r", "kind": "dir", "children": children } }); + + let walked = walk(&tree, 10); + assert_eq!(walked.paths, vec!["after-the-folders.rs"]); + assert!(!walked.truncated); + } + + #[test] + fn limit_stops_the_walk_and_says_so() { + let walked = walk(&sample(), 2); + assert_eq!(walked.paths.len(), 2); + assert!(walked.truncated, "two of three files is a partial listing"); + } + + #[test] + fn an_empty_or_foreign_shape_yields_nothing_rather_than_panicking() { + assert!(walk(&json!({}), 10).paths.is_empty()); + assert!(walk(&json!({ "root": { "name": "x", "kind": "dir" } }), 10) + .paths + .is_empty()); + assert!(walk(&json!({ "root": 5 }), 10).paths.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!(walk(&tree, 10).paths, 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..56fd63f73 --- /dev/null +++ b/editor/src/workspace.rs @@ -0,0 +1,316 @@ +//! 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, + /// Opaque version of the content this buffer was read at — the same fact + /// `mtime` carries, in the form that survives two writes inside one second. + /// A surface saves against it by sending it as `expected_version`. + /// + /// Defaulted so a session persisted before this field existed still loads. + /// Empty means "unknown": a surface holding an empty version has only the + /// mtime to save against, which is the behaviour it had all along. + #[serde(default)] + pub version: String, + /// 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, + version: "1-0000000000000001".to_string(), + 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; + newer.version = "9-000000000000009f".to_string(); + s.upsert(newer); + assert_eq!(s.buffers.len(), 1); + assert_eq!(s.buffers[0].mtime, 99, "the newer read wins"); + assert_eq!( + s.buffers[0].version, "9-000000000000009f", + "the version travels with the mtime it was read at" + ); + } + + /// A session written before buffers carried a version must still load, + /// with the version reading as unknown rather than failing the parse. + #[test] + fn a_buffer_without_a_version_still_loads() { + let back: Session = + serde_json::from_str(r#"{"buffers":[{"path":"a.rs","mtime":7,"language":"rust"}]}"#) + .expect("an older buffer record parses"); + assert_eq!(back.buffers[0].mtime, 7); + assert!(back.buffers[0].version.is_empty()); + } + + #[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()]); + } + + /// 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(); + s.expand("src"); + s.expand("src"); + 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(); + 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"); + + 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"]); + 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`, 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.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] + 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..66a09d777 --- /dev/null +++ b/editor/tests/golden/schemas/editor.buffers.close.json @@ -0,0 +1,74 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..4c28ccdd7 --- /dev/null +++ b/editor/tests/golden/schemas/editor.buffers.list.json @@ -0,0 +1,60 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..85dbdfec6 --- /dev/null +++ b/editor/tests/golden/schemas/editor.delete.json @@ -0,0 +1,86 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..d97e760d5 --- /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 not every path in the workspace was ranked: it held more than `max_find_candidates`, or the folder walk stopped at its visit budget. Either way this ranking is over a prefix of the workspace.", + "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..e17c51e15 --- /dev/null +++ b/editor/tests/golden/schemas/editor.git.hunks.json @@ -0,0 +1,217 @@ +{ + "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" + }, + { + "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" + } + ] + } + }, + "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 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": [ + "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" + }, + { + "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" + } + ] + }, + "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..a44297673 --- /dev/null +++ b/editor/tests/golden/schemas/editor.move.json @@ -0,0 +1,89 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..03c236308 --- /dev/null +++ b/editor/tests/golden/schemas/editor.open.json @@ -0,0 +1,63 @@ +{ + "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 and content version 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" + }, + "version": { + "description": "Opaque version of the `content` above. Pass it back as `expected_version` on `editor::save` for a guard that does not depend on the filesystem's timestamp resolution: it catches a write that landed inside the same second, which `mtime` cannot. Compare it for equality only — the encoding is this worker's business and may change.", + "type": "string" + } + }, + "required": [ + "content", + "language", + "mtime", + "path", + "size", + "truncated", + "version" + ], + "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..8bad3f282 --- /dev/null +++ b/editor/tests/golden/schemas/editor.save.json @@ -0,0 +1,114 @@ +{ + "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.\n\nResolution is one second, which is all the filesystem reports. Two writes inside the same second are therefore indistinguishable to this field, and the second one wins silently — send `expected_version` instead to close that window.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "expected_version": { + "default": null, + "description": "The `version` from the `editor::open` — or from the previous `editor::save` — this edit started from. It is a version of the *content*, so it catches a write that landed inside the same filesystem second and it does not care whether a clock or a checkout moved the mtime.\n\nWhen this is present it is the guard and `expected_mtime` is ignored. When it is absent the `expected_mtime` comparison applies exactly as before, so a caller that has never heard of a version is unaffected.", + "type": [ + "string", + "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" + ] + }, + "disk_version": { + "description": "Version of the disk contents when a conflict was detected — the value to send as `expected_version` once you have reconciled with them.", + "type": [ + "string", + "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" + }, + "version": { + "description": "Version of the content this file now holds: what was written on success, what is on disk on a conflict. Feed it into the next save as `expected_version`.", + "type": "string" + } + }, + "required": [ + "added", + "conflict", + "created", + "mtime", + "path", + "removed", + "saved", + "version" + ], + "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..fa4580d22 --- /dev/null +++ b/editor/tests/golden/schemas/editor.workspace.get.json @@ -0,0 +1,70 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..b02745bac --- /dev/null +++ b/editor/tests/golden/schemas/editor.workspace.open.json @@ -0,0 +1,79 @@ +{ + "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" + }, + "version": { + "default": "", + "description": "Opaque version of the content this buffer was read at — the same fact `mtime` carries, in the form that survives two writes inside one second. A surface saves against it by sending it as `expected_version`.\n\nDefaulted so a session persisted before this field existed still loads. Empty means \"unknown\": a surface holding an empty version has only the mtime to save against, which is the behaviour it had all along.", + "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..d6b56ff3b --- /dev/null +++ b/editor/tests/integration.rs @@ -0,0 +1,221 @@ +//! 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() +} + +/// The shorter of `cap` and what is left before `deadline`. +/// +/// Every wait in a poll loop goes through this. Without it the last attempt gets +/// its full timeout *plus* a full retry sleep on top of an already-spent budget, +/// which is how a 30-second budget takes 40 seconds to give up. +fn within(deadline: std::time::Instant, cap: Duration) -> Duration { + deadline + .saturating_duration_since(std::time::Instant::now()) + .min(cap) +} + +/// Poll until the engine accepts connections, or `budget` runs out. +async fn wait_for_engine(budget: Duration) -> bool { + let deadline = std::time::Instant::now() + budget; + while std::time::Instant::now() < deadline { + if engine_already_running() { + return true; + } + sleep(within(deadline, Duration::from_millis(50))).await; + } + engine_already_running() +} + +/// Pins the budget arithmetic, which is the part of the poll loops that has no +/// engine in it and so can be checked on any machine. +#[test] +fn no_single_wait_outlives_the_budget() { + let deadline = std::time::Instant::now() + Duration::from_millis(10); + assert!( + within(deadline, Duration::from_secs(10)) <= Duration::from_millis(10), + "a 10s cap must not survive a 10ms budget" + ); + assert_eq!( + within(std::time::Instant::now(), Duration::from_millis(250)), + Duration::ZERO, + "a spent budget buys no more sleeping" + ); + assert_eq!( + within( + std::time::Instant::now() + Duration::from_secs(600), + Duration::from_millis(250) + ), + Duration::from_millis(250), + "with time to spare the cap still applies" + ); +} + +/// `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 Some(iii_bin) = which::which("iii").ok() else { + eprintln!("skipping: the `iii` binary is not on PATH"); + return None; + }; + + 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()?; + + // Wait for the port rather than for a number of milliseconds. A fixed sleep + // is either too long on an idle machine or too short on a loaded runner, and + // the too-short case reads as "the worker never registered" rather than as + // "the engine had not started". Not being ready in time is not fatal here: + // the worker retries its own handshake and `await_function` polls, so the + // loud failure stays where it belongs. + if !wait_for_engine(Duration::from_secs(10)).await { + eprintln!("engine port did not open in 10s; continuing and letting the poll decide"); + } + + // `?` 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]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(e) => { + eprintln!("failed to spawn the worker: {e}"); + let mut iii = iii; + let _ = iii.kill(); + let _ = iii.wait(); + return None; + } + }; + + 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(within(deadline, 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(within(deadline, Duration::from_millis(250))).await; + } + eprintln!("gave up waiting for editor::diff: {last:?}"); + None +} + +#[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 { + return; + }; + + let client = register_worker(ENGINE_WS, InitOptions::default()); + + // 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); + 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..d9d1c1f36 --- /dev/null +++ b/editor/tests/schemas.rs @@ -0,0 +1,111 @@ +//! 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 +//! 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..4a805e0ef --- /dev/null +++ b/editor/ui/build.mjs @@ -0,0 +1,420 @@ +/** + * 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. + * + * --------------------------------------------------------------------------- + * 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'], + 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 = / , + }) +} 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..812ed2e2d --- /dev/null +++ b/editor/ui/src/function-trigger-message/index.tsx @@ -0,0 +1,338 @@ +/** + * 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, type FunctionTriggerMessage, type FunctionTriggerRenderer, type Host } from '@iii-dev/console-ui' +import { DEFAULT_THEMES, parsePatchFiles } from '@pierre/diffs' +import { FileDiff } from '@pierre/diffs/react' +import { useMemo } from 'react' + +import { contentHash } from '../lib/cache-key' + +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', +]) + +/** + * Files a patch may show in a feed row before it is cut short. A feed row is + * not a viewer: the page's `head` and `unsaved` views are where a long change + * gets read in full. + */ +const MAX_PATCH_FILES = 8 + +/** Threaded down so pierre renders inside its own shadow root. */ +const DIFF_OPTIONS = { theme: DEFAULT_THEMES, diffStyle: 'unified', overflow: 'scroll' } as const + +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 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} + + ) +} + +/** + * 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${contentHash(patch)}`).flatMap((p) => p.files), [patch]) + if (files.length === 0) return null + + const shown = files.slice(0, MAX_PATCH_FILES) + return ( +
+ {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 +
+ )} +
+ ) +} + +/** 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, host: Host) { + 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, host: Host) { + 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, host) + case 'editor::save': + return renderSave(output, host) + 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..4487e8344 --- /dev/null +++ b/editor/ui/src/lib/api.ts @@ -0,0 +1,288 @@ +/** + * 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 + /** + * Opaque content version, and the reason it exists: `mtime` is Unix + * *seconds*, so two writes inside the same second are indistinguishable and + * a stale `expected_mtime` still matches, letting a save overwrite an edit + * it never saw. This carries the same fact in a form that survives that. + * + * Empty means unknown, which is what a session persisted before this field + * existed deserialises to. Callers fall back to `mtime` then, so an old + * workspace keeps working exactly as it did. + */ + version: 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 + version: string + truncated: boolean +} + +export interface SaveResult { + path: string + saved: boolean + conflict: boolean + mtime: number + version: string + disk_mtime: number | null + disk_version: string | 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), + // Both guards go out together, and the worker prefers the version when it + // has one. Sending `expected_mtime` as well is what keeps this page working + // against a worker built before `expected_version` existed: that worker + // ignores the field it does not know and compares mtimes, exactly as + // before. An empty version means the buffer predates the field, so it is + // omitted rather than sent as a value that would never match. + save: (path: string, content: string, expectedMtime: number | null, expectedVersion?: string | null) => + call( + 'editor::save', + { + path, + content, + ...(expectedMtime === null ? {} : { expected_mtime: expectedMtime }), + ...(expectedVersion ? { expected_version: expectedVersion } : {}), + }, + 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 + // 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, + }), + 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/lib/cache-key.ts b/editor/ui/src/lib/cache-key.ts new file mode 100644 index 000000000..931ddb420 --- /dev/null +++ b/editor/ui/src/lib/cache-key.ts @@ -0,0 +1,42 @@ +/** + * Cache keys for `@pierre/diffs`. + * + * pierre memoises on `cacheKey`, and it trusts it: `areDiffTargetsEqual` + * compares nothing else, and `FileRenderer`'s line cache is keyed on it alone. + * A key that fails to move when the text moves therefore does not render + * slightly stale — it renders the *previous* file. + * + * This page used to key on the path and the content *length*, which meant every + * edit that left the length alone showed the old content. Swapping one + * character does exactly that, which is not an edge case: it is a typo fix. + */ + +/** + * A 32-bit FNV-1a hash of `text`, base-36 encoded. + * + * Rolling and allocation-free apart from the accumulator, so it costs one pass + * over the string. Call sites hold it behind a `useMemo` keyed on the same + * text, so it runs once per change rather than once per render. + * + * The length is folded in at the end, which makes the key strictly stronger + * than either the hash or the length alone: two strings now have to agree on + * both to collide. + * + * A 32-bit hash *can* collide, and if it does the stale render is back for that + * one pair of contents. That trade is the right way round: a collision needs + * two specific strings to land on the same word out of four billion, whereas + * keying on length was not a risk but a certainty — every same-length edit hit + * it, every time. A wider or cryptographic hash would buy a rounding error of + * additional safety for a per-keystroke cost over the whole buffer. + */ +export function contentHash(text: string): string { + let hash = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + hash ^= text.length + hash = Math.imul(hash, 0x01000193) + // `>>> 0` reads the accumulator as unsigned; base 36 keeps the key short. + return (hash >>> 0).toString(36) +} 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 new file mode 100644 index 000000000..4d7c87f8f --- /dev/null +++ b/editor/ui/src/page/index.tsx @@ -0,0 +1,1167 @@ +/** + * 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 + * 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. + * + * 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 { + Badge, + Button, + CodeEditor, + CodeHighlight, + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + EmptyState, + type Host, + Input, + MarkdownPreview, + 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 { + type Api, + type Buffer, + createApi, + errorText, + isDir, + isNotARepo, + type SaveResult, + type SearchResult, + type StatusEntry, + type StatusReport, + type SyncAction, + statusMark, + type TreeNode, + visibleRows, +} from '../lib/api' +import { contentHash } from '../lib/cache-key' +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 + +/** + * Trailing-edge window for folding pushed events into one refresh, in ms. + * + * Events arrive one per write, and the case this page exists for — an agent + * editing a directory in a loop — delivers them in bursts. A refresh per event + * costs a workspace read, a git status and a file read each time, and lets a + * slow pass overlap the next. + * + * 100ms is short on purpose. The push rewrite exists so that you watch the + * agent work, so an isolated event has to land immediately rather than + * eventually, and 100ms is inside the window where a UI still reads as + * immediate. Nothing user-visible waits on it either: the row accent and the + * status line are set straight from the event payload, before the timer is + * armed. Raising this buys fewer round trips at the cost of the one property + * the feature exists for. + */ +const COALESCE_MS = 100 + +/** A cancellable pause, so a sustained stream of events gets a window between + * passes instead of back-to-back round trips. */ +const settle = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * 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 { + base: string + draft: string + /** + * The mtime the file had when `base` was read. + * + * Per draft rather than in a map of its own so it cannot drift from the text + * it describes: it is set wherever `base` is and dropped with the tab. Its + * one job is the gate in `syncWorkspace` — a workspace buffer whose recorded + * mtime still matches this one has not been written *through the worker* + * since this page read it, so re-reading it would spend a file body for + * nothing. + */ + mtime: number + 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]) + // Follows the console's own light/dark toggle, not the OS preference. + const themeType = host.useTheme() + + 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) + // Reading is the default: opening a file should show you the file, not put + // a cursor in it. + const [view, setView] = useState<'read' | 'edit' | 'preview' | 'diff' | 'git'>('read') + 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) + const [lastChange, setLastChange] = useState(null) + + // Read inside a refresh without making it a dependency — rebuilding the + // refresh callbacks on every keystroke would re-run the effects that bind + // them and churn the subscriptions. + 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 + // The worker classifies the language, so the extension check is only a + // fallback for a file it did not recognise. + const markdown = activeBuffer?.language === 'markdown' || /\.(md|markdown|mdx)$/i.test(activePath ?? '') + + // Two of the tabs only exist for some files: `unsaved` while something is + // unsaved, `preview` on markdown. Saving, undoing back to the original, or + // switching to a tab of another kind all take a button away, and leaving the + // view pointed at it would strand the user somewhere with no way back and + // nothing in it. Fall back to `read`, which every file has. + useEffect(() => { + if (view === 'diff' && !dirty) setView('read') + if (view === 'preview' && !markdown) setView('read') + }, [view, dirty, markdown]) + + 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, + mtime: file.mtime, + 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, + mtime: file.mtime, + truncated: file.truncated, + stale: false, + }, + })) + applyWorkspace(await api.workspace()) + setActivePath(path) + setView('read') + } 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 one open tab forward from disk. + * + * An untouched buffer adopts the new text. An edited one keeps the edit and + * gains `stale`, which raises the "disk moved" badge and leaves the save + * guard to refuse the write rather than overwriting silently. + * + * The mtime is recorded even when the content came back identical, and that + * is not tidiness: `editor::open` upserts the buffer into the shared + * workspace, so this read moves the *record's* mtime. A local copy left + * behind would make the gate in `syncWorkspace` re-read the same file on the + * state event this very call is about to emit, which re-opens it, which emits + * another — a loop with no exit. + */ + const reread = useCallback( + async (path: string) => { + const local = draftsRef.current[path] + if (!local) return + try { + const file = await api.open(path) + const moved = file.content !== local.base + let next: Draft = { ...local, mtime: file.mtime } + if (moved) { + next = + local.draft === local.base + ? { + base: file.content, + draft: file.content, + mtime: file.mtime, + truncated: file.truncated, + stale: false, + } + : { ...next, stale: true } + } + // Closed while the read was in flight: do not resurrect the tab. + setDrafts((prev) => (prev[path] ? { ...prev, [path]: next } : prev)) + if (moved) setFlashed((prev) => ({ ...prev, [path]: Date.now() })) + } catch { + // A file that cannot be read keeps the text we already have; the error + // surfaces when the user selects the tab. + } + }, + [api], + ) + + /** + * Re-read the shared workspace and pull forward only the tabs that moved. + * + * `mtime` on a workspace buffer is the mtime the *worker* last read that file + * at — it advances on `editor::open` and `editor::save` and nowhere else — so + * comparing it against the mtime this page recorded when it read the file + * identifies exactly which tabs were saved through the worker by another + * surface or an agent. The rest keep the text they have instead of costing a + * full file body each, which is what makes this affordable per event rather + * than per timer tick. + * + * The gate is only sound *here*. A write that never went through this worker + * (`shell::fs::write`) does not move the record's mtime at all; those arrive + * as `editor::changed` with the path in the payload and are re-read + * unconditionally, which is why the changed path is passed to `reread` + * directly instead of being looked for here. + * + * Residual: mtime is Unix *seconds*, so a save made through the worker inside + * the same second as this page's last read of that file is indistinguishable + * from no change and waits for the next event touching it. Reading + * unconditionally instead is not an option — see `reread`, it does not + * terminate. The worker's buffer record carries an opaque `version` that is + * the same fact without the one-second floor; when the save path starts + * sending it, this gate should compare that in preference to the mtime and + * the residual goes away. + */ + const syncWorkspace = 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 || buffer.mtime === local.mtime) continue + await reread(buffer.path) + } + } catch { + // Transient; the next event tries again. + } + }, [api, reread]) + + /** Work accumulated since the last pass: paths whose file needs re-reading, + * and whether the git overlay is stale. */ + const pendingRef = useRef<{ paths: Set; git: boolean }>({ paths: new Set(), git: false }) + /** Set by every event, cleared as a pass takes the work. */ + const wantedRef = useRef(false) + const runningRef = useRef(false) + const timerRef = useRef | null>(null) + + /** + * Drain the accumulated work, one pass at a time. + * + * Exactly one pass is ever in flight. The guard **defers rather than drops**: + * an event that lands mid-pass stays in `pendingRef` and the loop picks it up + * on its next turn, so the last event of a burst is always the one the page + * settles on. Discarding it instead would leave the UI showing content that + * is merely old, which is worse than the burst being fixed here. + */ + const flush = useCallback(async () => { + if (runningRef.current) return + runningRef.current = true + try { + while (wantedRef.current) { + wantedRef.current = false + const work = pendingRef.current + pendingRef.current = { paths: new Set(), git: false } + + await syncWorkspace() + // One read per changed path. The event carries a patch, not the new + // content, and reconstructing the file from a patch here is the hand + // parsing this page deliberately no longer does. + for (const path of work.paths) await reread(path) + if (work.git) await refreshGit() + + // Anything that arrived during the pass gets its own window rather than + // an immediate re-run, so a sustained stream costs one pass per window + // instead of one pass per bus round trip. + if (wantedRef.current) await settle(COALESCE_MS) + } + } finally { + runningRef.current = false + } + }, [refreshGit, reread, syncWorkspace]) + + /** + * Record what an event implies and arm the window. + * + * The timer is deliberately *not* restarted per event: a stream arriving + * faster than `COALESCE_MS` would then postpone the refresh for as long as it + * lasted, which is the one failure this cannot have. Arming once per window + * makes the window a floor on refresh spacing rather than a ceiling on how + * long a change can stay invisible. + */ + const schedule = useCallback( + (path?: string, git = false) => { + if (path !== undefined) pendingRef.current.paths.add(path) + if (git) pendingRef.current.git = true + wantedRef.current = true + if (timerRef.current !== null) return + timerRef.current = setTimeout(() => { + timerRef.current = null + void flush() + }, COALESCE_MS) + }, + [flush], + ) + + useEffect( + () => () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + }, + [], + ) + + 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(() => { + void refreshGit() + }, [refreshGit]) + + // A change to this worker's `state` scope means the shared workspace moved: + // an agent opened or closed something, or saved. The record itself says which + // buffers moved, so no path is named here. + useEffect(() => onState(() => schedule()), [onState, schedule]) + + // A file changed, however it changed. The accent and the status line are set + // straight from the payload so the page reacts within the frame; only the + // re-read and the git marks wait for the window. + useEffect( + () => + onChanged((event: ChangedEvent) => { + setFlashed((prev) => ({ ...prev, [event.path]: Date.now() })) + setLastChange(event) + schedule(event.path, true) + }), + [onChanged, schedule], + ) + + 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, activeBuffer.version) + if (result.conflict) { + setConflict(result) + } else { + setDrafts((prev) => ({ + ...prev, + // `result.mtime` is what the file now has on disk. Recording it keeps + // the `syncWorkspace` gate quiet about the buffer this save just + // upserted into the shared workspace. + [activePath]: { ...activeDraft, base: activeDraft.draft, mtime: result.mtime, 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 && ( + <> +
+
+ + + {/* + 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 && ( + + )} + +
+ + {activeDraft.stale && disk moved} + +
+ + {activeDraft.truncated && ( +
+ Larger than max_file_bytes — only the beginning was read, so saving is refused. +
+ )} + + {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' ? ( +
+ + 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'} + {lastChange && ( + + {lastChange.kind} {lastChange.path.split('/').pop()} + + )} +
+ + )} + + )} +
+
+ + {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. + + {/* 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. */} + +
+ + +
+
+
+
+ ) +} + +/** + * One file, read-only, with the chrome a reader wants. + * + * `cacheKey` is the memo key pierre hands its render cache, so it has to move + * whenever the text or the name does or the pane keeps showing the previous + * file. `path` alone does not move when a file is edited in place, so the + * contents are hashed — see `contentHash`, which is where the earlier + * length-only key and what it got wrong are written down. + */ +function FileView({ path, contents, themeType }: { path: string; contents: string; themeType: 'light' | 'dark' }) { + const file = useMemo(() => ({ name: path, contents, cacheKey: `${path}:${contentHash(contents)}` }), [contents, path]) + return ( +
+ +
+ ) +} + +/** + * A unified patch, rendered by pierre. + * + * Parsed here rather than handed to pierre's `PatchDiff`, which throws unless + * the text yields exactly one file with exactly one diff — an empty patch (a + * clean file) and a multi-file patch both take the page down with it. + * `parsePatchFiles` does not throw (`throwOnError` defaults false), so the + * empty case is a hint and the multi-file case is a `FileDiff` per file. + * + * The `cacheKeyPrefix` argument gives every parsed file a cache key derived + * from the prefix and its position, which is what keeps pierre from re-reading + * an unchanged hunk on each render. + */ +function PatchView({ patch, themeType }: { patch: string; themeType: 'light' | 'dark' }) { + const files = useMemo( + () => (patch.trim() === '' ? [] : parsePatchFiles(patch, `p${contentHash(patch)}`).flatMap((p) => p.files)), + [patch], + ) + + if (files.length === 0) return
no changes
+ + return ( +
+ {files.map((file, index) => ( + + ))} +
+ ) +} + +/** The buffer against its last read: what saving would write. */ +function LocalDiff({ + api, + path, + base, + draft, + themeType, +}: { + api: Api + path: string + base: string + draft: string + themeType: 'light' | 'dark' +}) { + 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..4dc9c1dbf --- /dev/null +++ b/editor/ui/styles.css @@ -0,0 +1,584 @@ +/* + * 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; +} + +/* 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: transparent; + font-size: 13px; + line-height: 1; + padding: 0 2px; + cursor: pointer; +} + +[data-iii-ui='editor'] .ed-tab:hover .ed-close, +[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 { + 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; +} + +/* 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; +} + +/* 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. */ +[data-iii-ui='editor'] .ed-reader { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* The status line carries what the *edit* surface cannot: the console's 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. The + read view has pierre's own gutter, and this line still agrees with it. */ +[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); +} + +/* The rows this page used to paint for a patch — a banded `.ed-dline` per + line, with numbers counted off the `@@` headers — are gone. @pierre/diffs + owns diff presentation now, including the add and delete colours, and it + paints them inside its own shadow root where none of these tokens reach. */ + +/* 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; +} 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..f579a6c45 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -83,9 +83,15 @@ 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.) + # 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 @@ -201,6 +207,27 @@ 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 + - 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcac8c9af..3f06cde9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,25 @@ importers: specifier: ^5.9.2 version: 5.9.3 + editor/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + '@pierre/diffs': + specifier: ^1.2.7 + version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + 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