From 23981ce2ae61564d3a38e485234e35652da053c2 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 21:36:49 +0200 Subject: [PATCH] Implement the terminal UI --- Cargo.lock | 1094 ++++++- Cargo.toml | 3 + README.md | 3 +- RUST_PLAN_EXTENSION.md | 11 +- crates/bds-cli/Cargo.toml | 1 + crates/bds-cli/src/main.rs | 21 + crates/bds-server/Cargo.toml | 6 + crates/bds-server/src/host.rs | 53 +- crates/bds-server/src/lib.rs | 1 + crates/bds-server/src/transport.rs | 218 +- crates/bds-server/src/tui.rs | 4479 ++++++++++++++++++++++++++++ locales/ui/de.ftl | 30 + locales/ui/en.ftl | 30 + locales/ui/es.ftl | 30 + locales/ui/fr.ftl | 30 + locales/ui/it.ftl | 30 + 16 files changed, 5836 insertions(+), 204 deletions(-) create mode 100644 crates/bds-server/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 669d1cd..806a43f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-sink", @@ -45,7 +45,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "derive_more", "futures-core", @@ -69,7 +69,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.11.0", + "bitflags 2.13.1", "brotli", "bytes", "bytestring", @@ -347,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.11.0", + "bitflags 2.13.1", "cc", "jni 0.22.4", "libc", @@ -374,6 +374,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi-to-tui" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42366bb9d958f042bf58f0a85e1b2d091997c1257ca49bddd7e4827aadc65fd" +dependencies = [ + "nom 8.0.0", + "ratatui-core", + "simdutf8", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "anstream" version = "1.0.0" @@ -720,6 +733,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -869,6 +891,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -892,6 +924,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bds-core", + "bds-server", "clap", "dirs 5.0.1", "serde", @@ -967,13 +1000,19 @@ version = "0.1.0" dependencies = [ "anyhow", "bds-core", + "bds-editor", + "chrono", "clap", "dirs 5.0.1", + "image 0.25.10", + "ratatui", + "ratatui-image", "russh", "serde", "serde_json", "tempfile", "tokio", + "tui-markdown", "uuid", ] @@ -1077,9 +1116,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1093,6 +1132,18 @@ dependencies = [ "core2", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.11.0-rc.6" @@ -1279,7 +1330,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -1304,7 +1355,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -1318,7 +1369,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "polling", "rustix 1.1.4", "slab", @@ -1834,7 +1885,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "fontdb 0.16.2", "log", "rangemap", @@ -1884,6 +1935,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1918,6 +1975,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot 0.12.5", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1972,6 +2056,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -2121,7 +2215,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "libloading 0.8.9", "winapi", ] @@ -2162,6 +2256,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2190,6 +2294,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2212,6 +2329,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -2271,6 +2399,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.8.1" @@ -2412,6 +2546,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -2508,7 +2648,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -2588,7 +2728,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "drm-ffi", "drm-fourcc", @@ -2912,6 +3052,16 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -2995,12 +3145,35 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -3164,6 +3337,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -3248,6 +3427,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -3517,7 +3702,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -3558,6 +3743,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.13.1" @@ -3596,7 +3787,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "gpu-alloc-types", ] @@ -3606,7 +3797,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -3628,7 +3819,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.14.5", ] @@ -3639,7 +3830,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -3786,13 +3977,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hassle-rs" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "com", "libc", "libloading 0.8.9", @@ -4091,7 +4293,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "dark-light", "glam", @@ -4128,7 +4330,7 @@ checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" dependencies = [ "cosmic-text", "etagere", - "lru", + "lru 0.12.5", "rustc-hash 2.1.2", "wgpu", ] @@ -4139,7 +4341,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "cosmic-text", "half", @@ -4205,7 +4407,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "futures", "glam", @@ -4340,6 +4542,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -4497,6 +4709,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.2.2" @@ -4507,6 +4728,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "instant" version = "0.1.13" @@ -4608,6 +4842,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -4751,6 +4994,17 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.2.0" @@ -4777,7 +5031,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -4845,6 +5099,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "language-tags" version = "0.3.2" @@ -4983,7 +5243,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.7.3", @@ -5019,6 +5279,15 @@ dependencies = [ "x11", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "link-cplusplus" version = "1.0.12" @@ -5071,7 +5340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc623edee8a618b4543e8e8505584f4847a4e51b805db1af6d9af0a3395d0d57" dependencies = [ "anymap2", - "itertools", + "itertools 0.14.0", "kstring", "liquid-derive", "pest", @@ -5098,7 +5367,7 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9befeedd61f5995bc128c571db65300aeb50d62e4f0542c88282dbcb5f72372a" dependencies = [ - "itertools", + "itertools 0.14.0", "liquid-core", "percent-encoding", "regex", @@ -5156,7 +5425,7 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cssparser", "encoding_rs", @@ -5184,6 +5453,15 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -5267,6 +5545,16 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -5362,6 +5650,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -5377,7 +5671,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block", "core-graphics-types", "foreign-types", @@ -5602,7 +5896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" dependencies = [ "bit-set 0.5.3", - "bitflags 2.11.0", + "bitflags 2.13.1", "codespan-reporting 0.11.1", "hexf-parse", "indexmap 2.13.0", @@ -5636,7 +5930,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -5681,7 +5975,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -5694,7 +5988,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -5813,6 +6107,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "numkong" version = "7.7.0" @@ -5864,7 +6167,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5880,7 +6183,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -5901,7 +6204,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -5914,7 +6217,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5936,7 +6239,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5948,7 +6251,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5959,7 +6262,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -5970,7 +6273,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -6017,7 +6320,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6029,7 +6332,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6057,7 +6360,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -6070,7 +6373,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6083,7 +6386,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6106,7 +6409,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6118,7 +6421,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -6131,7 +6434,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6153,7 +6456,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -6174,7 +6477,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6197,7 +6500,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -6210,7 +6513,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -6245,7 +6548,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -6297,6 +6600,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -6341,6 +6662,12 @@ dependencies = [ "ureq", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -6469,7 +6796,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ "approx", + "bytemuck", "fast-srgb8", + "libm", "palette_derive", "phf 0.11.3", ] @@ -6697,6 +7026,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" @@ -6903,7 +7242,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -7021,6 +7360,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -7145,7 +7494,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -7158,7 +7507,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -7186,6 +7535,26 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quantette" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" +dependencies = [ + "bitvec", + "bytemuck", + "image 0.25.10", + "libm", + "num-traits", + "ordered-float 5.3.0", + "palette", + "rand 0.9.2", + "rand_xoshiro", + "rayon", + "ref-cast", + "wide", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -7295,6 +7664,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -7371,6 +7746,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -7383,6 +7767,124 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru 0.18.1", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-image" +version = "11.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000b7a22eae639460bc6ec8bb1cc689ecae5b0ed21935cd7d7dd52d38270c86" +dependencies = [ + "base64-simd", + "icy_sixel", + "image 0.25.10", + "rand 0.8.5", + "ratatui", + "rustix 0.38.44", + "self_cell", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -7399,7 +7901,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -7462,7 +7964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -7510,7 +8012,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -7519,7 +8021,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -7544,6 +8046,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "regex" version = "1.12.3" @@ -7579,6 +8101,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -7745,6 +8273,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "russh" version = "0.62.2" @@ -7753,7 +8310,7 @@ checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.11.0", + "bitflags 2.13.1", "block-padding", "byteorder", "bytes", @@ -7909,7 +8466,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7922,7 +8479,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7977,7 +8534,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytemuck", "libm", "smallvec", @@ -7994,6 +8551,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" +dependencies = [ + "bytemuck", +] + [[package]] name = "safetensors" version = "0.8.0" @@ -8089,7 +8655,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -8102,7 +8668,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8125,13 +8691,13 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cssparser", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.2", "servo_arc", @@ -8144,13 +8710,13 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cssparser", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.2", "servo_arc", @@ -8185,7 +8751,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -8389,6 +8955,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -8492,7 +9079,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -8517,7 +9104,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -8653,7 +9240,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -8807,6 +9394,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8947,6 +9555,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -8985,6 +9599,82 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot 0.12.5", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -9078,7 +9768,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -9179,7 +9871,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -9399,7 +10091,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "futures-util", "http 1.4.0", @@ -9479,6 +10171,22 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tui-markdown" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6779056a6ae2d46013edb3692be1eb55d2e1d3f0d1d454042163efe1b64baca" +dependencies = [ + "ansi-to-tui", + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark 0.13.3", + "ratatui-core", + "rstest", + "syntect", + "tracing", +] + [[package]] name = "twelf" version = "0.15.0" @@ -9644,6 +10352,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-vo" version = "0.1.0" @@ -9834,6 +10553,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -9875,6 +10595,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -10029,7 +10764,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -10042,7 +10777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f8cbf8125142b9b30321ac8721f54c52fbcd6659f76cf863d5e2e38c07a3d7b" dependencies = [ "const_format", - "itertools", + "itertools 0.14.0", "nom 7.1.3", "pori", "regex", @@ -10070,7 +10805,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -10082,7 +10817,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -10104,7 +10839,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -10116,7 +10851,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10129,7 +10864,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10142,7 +10877,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10155,7 +10890,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10212,7 +10947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] @@ -10280,8 +11015,8 @@ dependencies = [ "webview2-com-sys", "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -10312,6 +11047,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "wgpu" version = "0.19.4" @@ -10345,7 +11152,7 @@ checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" dependencies = [ "arrayvec", "bit-vec 0.6.3", - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg_aliases 0.1.1", "codespan-reporting 0.11.1", "indexmap 2.13.0", @@ -10373,7 +11180,7 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.5.3", - "bitflags 2.11.0", + "bitflags 2.13.1", "block", "cfg_aliases 0.1.1", "core-graphics-types", @@ -10414,7 +11221,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "js-sys", "web-sys", ] @@ -10428,6 +11235,16 @@ dependencies = [ "libc", ] +[[package]] +name = "wide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" @@ -10489,6 +11306,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -10541,14 +11368,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -10560,8 +11400,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -10589,6 +11429,17 @@ dependencies = [ "windows-threading 0.2.1", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -10600,6 +11451,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -10643,6 +11505,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -10661,6 +11532,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -11012,7 +11893,7 @@ dependencies = [ "ahash 0.8.12", "android-activity", "atomic-waker", - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -11159,7 +12040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.1", "indexmap 2.13.0", "log", "serde", @@ -11250,6 +12131,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -11314,7 +12204,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -11364,6 +12254,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index cf01dc8..3349893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,9 @@ fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub- usearch = "2.26" ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] } russh = "0.62.2" +ratatui = "0.30.2" +ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] } +tui-markdown = "0.3.8" # UI framework iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] } diff --git a/README.md b/README.md index 373ed15..f4fa411 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The project is under active development. Core blogging workflows are broadly ava - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. - Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration. +- A full Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client updates, locale changes, and airplane-mode AI gating. - Headless `bds-server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. @@ -53,4 +54,4 @@ Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS ## Headless server -Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. `BDS_MODE=tui` starts the same headless host with a terminal session attached to the launching shell. +Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. Run `bds-cli tui` or set `BDS_MODE=tui` for the local terminal workspace; an authenticated SSH shell opens the same workspace against server-side data and locale. Press `:` for its command list and `:?` for help. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 0f406cb..97943d0 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -99,13 +99,14 @@ Done: - Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown. - The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint. -### Terminal UI — Open +### Terminal UI — Complete -Open: +Done: -- Terminal renderer over shared application workflows. -- Sidebar/editor navigation, editing, publishing, and live domain-event updates. -- Remote operation through the headless server. +- One Ratatui state/renderer runs locally through the CLI and in authenticated SSH PTYs; resize, disconnect, reconnect, clean exit, and terminal restoration are covered by a real loopback PTY test. +- Numbered sidebar views, per-view filtering, project switching/path completion, command overlays, Markdown preview, syntax-highlighted soft-wrapped Markdown/Liquid/Lua editing, validation, save/publish/unpublish, confirmations, and live domain-event synchronization use the shared engines. +- Typed settings, complete tag workflows, Git status/diff/history/commit/pull/push, metadata/site reports with apply/cancel, managed background progress, locale changes, media information, and airplane-gated AI actions are terminal-accessible. +- State, renderer-buffer, input-decoder, shared local/remote persistence, task/report, event, locale, and real SSH PTY behavior have isolated tests. ### A2UI surfaces — Complete diff --git a/crates/bds-cli/Cargo.toml b/crates/bds-cli/Cargo.toml index 6d8d196..47460bc 100644 --- a/crates/bds-cli/Cargo.toml +++ b/crates/bds-cli/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] bds-core = { workspace = true } +bds-server = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } dirs = { workspace = true } diff --git a/crates/bds-cli/src/main.rs b/crates/bds-cli/src/main.rs index fd54621..6b1e924 100644 --- a/crates/bds-cli/src/main.rs +++ b/crates/bds-cli/src/main.rs @@ -20,6 +20,27 @@ fn main() -> ExitCode { } }; + if matches!(cli.command, bds_cli::Command::Tui) { + let data_root = bds_core::util::application_data_dir(); + let config = match bds_server::ServerConfig::from_environment( + bds_core::util::application_database_path(), + data_root, + ) { + Ok(config) => config, + Err(error) => { + eprintln!("Error: {error:#}"); + return ExitCode::from(1); + } + }; + return match bds_server::run_headless(bds_server::boot::BootMode::Tui, config) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("Error: {error:#}"); + ExitCode::from(1) + } + }; + } + let json = cli.json; let needs_stdin = match &cli.command { bds_cli::Command::Post(args) => args.stdin, diff --git a/crates/bds-server/Cargo.toml b/crates/bds-server/Cargo.toml index ed23a53..33320bc 100644 --- a/crates/bds-server/Cargo.toml +++ b/crates/bds-server/Cargo.toml @@ -6,13 +6,19 @@ license.workspace = true [dependencies] bds-core = { workspace = true } +bds-editor = { workspace = true } anyhow = { workspace = true } +chrono = { workspace = true } clap = { workspace = true } dirs = { workspace = true } +image = { workspace = true } russh = { workspace = true } +ratatui = { workspace = true } +ratatui-image = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +tui-markdown = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/crates/bds-server/src/host.rs b/crates/bds-server/src/host.rs index 617bfc0..55a5f86 100644 --- a/crates/bds-server/src/host.rs +++ b/crates/bds-server/src/host.rs @@ -15,44 +15,7 @@ use serde_json::{Value, json}; use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage}; pub fn run_local_terminal(host: ApplicationHost) -> Result<()> { - use std::io::{BufRead as _, Write as _}; - - let mut session = host.session()?; - let _ = session.handle(Request { - id: "local-terminal-hello".into(), - command: Command::Hello { - protocol_version: PROTOCOL_VERSION, - }, - }); - let projects = session.handle(Request { - id: "local-terminal-projects".into(), - command: Command::ListProjects, - }); - let names = match projects { - ServerMessage::Response { result, .. } => result - .as_array() - .into_iter() - .flatten() - .filter_map(|project| project.get("name").and_then(Value::as_str)) - .collect::>() - .join("\n • "), - _ => String::new(), - }; - let locale = bds_core::i18n::normalize_language(session.locale()); - let title = bds_core::i18n::translate(locale, "remoteTerminal.localTitle"); - let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects"); - let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit"); - println!( - "\x1b[2J\x1b[H{title}\n\n{available}:\n • {}\n\n{quit}", - if names.is_empty() { "—" } else { &names } - ); - std::io::stdout().flush()?; - for line in std::io::stdin().lock().lines() { - if line?.trim().eq_ignore_ascii_case("q") { - break; - } - } - Ok(()) + crate::tui::run_local(host) } #[derive(Clone)] @@ -206,10 +169,22 @@ impl ApplicationHost { }) } - fn database(&self) -> Result { + pub(crate) fn database(&self) -> Result { Database::open(&self.inner.database_path).map_err(Into::into) } + pub(crate) fn database_path(&self) -> &std::path::Path { + &self.inner.database_path + } + + pub(crate) fn project_data_dir(&self, project: &bds_core::model::Project) -> PathBuf { + project + .data_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| self.inner.data_root.join("projects").join(&project.id)) + } + fn cached(&self, id: &str) -> Option { lock(&self.inner.completed_requests).get(id).cloned() } diff --git a/crates/bds-server/src/lib.rs b/crates/bds-server/src/lib.rs index f4b5fc2..828358d 100644 --- a/crates/bds-server/src/lib.rs +++ b/crates/bds-server/src/lib.rs @@ -4,6 +4,7 @@ pub mod client; pub mod host; pub mod protocol; pub mod transport; +pub mod tui; pub use client::{DesktopClient, RemoteTarget}; pub use transport::{ServerConfig, ServerRuntime}; diff --git a/crates/bds-server/src/transport.rs b/crates/bds-server/src/transport.rs index faf1616..1b49ed9 100644 --- a/crates/bds-server/src/transport.rs +++ b/crates/bds-server/src/transport.rs @@ -14,7 +14,7 @@ use tokio::io::{AsyncBufReadExt as _, AsyncWrite, AsyncWriteExt as _, BufReader} use crate::auth::KeyMaterial; use crate::host::{ApplicationHost, ApplicationSession}; -use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage}; +use crate::protocol::{Request, SUBSYSTEM, ServerMessage}; #[derive(Debug, Clone)] pub struct ServerConfig { @@ -249,10 +249,10 @@ impl Handler for ConnectionHandler { session.channel_failure(channel)?; return Ok(()); }; - let application = self.host.session()?; + let host = self.host.clone(); session.channel_success(channel.id())?; tokio::spawn(async move { - let _ = run_terminal_session(channel, application).await; + let _ = run_terminal_session(channel, host).await; }); Ok(()) } @@ -322,77 +322,78 @@ async fn write_message( Ok(()) } -async fn run_terminal_session( - mut channel: Channel, - mut application: ApplicationSession, -) -> Result<()> { - let locale = application.locale().to_owned(); - let hello = application.handle(Request { - id: "terminal-hello".into(), - command: Command::Hello { - protocol_version: PROTOCOL_VERSION, - }, - }); - if matches!(hello, ServerMessage::Error { .. }) { - return Err(anyhow!("terminal protocol negotiation failed")); - } - let projects = application.handle(Request { - id: "terminal-projects".into(), - command: Command::ListProjects, - }); - let project_names = match projects { - ServerMessage::Response { result, .. } => result - .as_array() - .into_iter() - .flatten() - .filter_map(|project| project.get("name").and_then(|name| name.as_str())) - .collect::>() - .join("\r\n • "), - _ => String::new(), - }; - let banner = terminal_banner(&locale, &project_names); - channel.data(banner.as_bytes()).await?; - let mut interval = tokio::time::interval(Duration::from_millis(250)); +async fn run_terminal_session(mut channel: Channel, host: ApplicationHost) -> Result<()> { + let mut app = crate::tui::TuiApp::new(host, true)?; + let mut decoder = crate::tui::InputDecoder::default(); + let (mut width, mut height) = (80_u16, 24_u16); + let mut dirty = true; + let mut restored = false; + let mut interval = tokio::time::interval(Duration::from_millis(100)); loop { tokio::select! { message = channel.wait() => match message { - Some(russh::ChannelMsg::Data { data }) if data.iter().any(|byte| matches!(byte, b'q' | 3)) => { - channel.close().await?; - break; + Some(russh::ChannelMsg::Data { data }) => { + for input in decoder.push(&data) { app.handle_input(input)?; } + dirty = true; + } + Some(russh::ChannelMsg::RequestPty { col_width, row_height, .. }) + | Some(russh::ChannelMsg::WindowChange { col_width, row_height, .. }) => { + width = u16::try_from(col_width).unwrap_or(u16::MAX).max(20); + height = u16::try_from(row_height).unwrap_or(u16::MAX).max(6); + dirty = true; } None | Some(russh::ChannelMsg::Close) => break, _ => {} }, _ = interval.tick() => { - let updates = application.pending(); - if !updates.is_empty() { - let status = format!("\r\n[{} update{}]\r\n", updates.len(), if updates.len() == 1 { "" } else { "s" }); - channel.data(status.as_bytes()).await?; - } + for input in decoder.flush() { app.handle_input(input)?; } + app.poll()?; + dirty = true; } } + if dirty { + channel + .data(&crate::tui::render_ansi(&mut app, width, height)[..]) + .await?; + dirty = false; + } + if app.should_quit() { + channel.data(&b"\x1b[0m\x1b[?25h\r\n"[..]).await?; + restored = true; + channel.close().await?; + break; + } + } + if !restored { + let _ = channel.data(&b"\x1b[0m\x1b[?25h\r\n"[..]).await; + let _ = channel.close().await; } Ok(()) } -fn terminal_banner(locale: &str, projects: &str) -> String { - let locale = bds_core::i18n::normalize_language(locale); - let title = bds_core::i18n::translate(locale, "remoteTerminal.serverTitle"); - let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects"); - let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit"); - let projects = if projects.is_empty() { "—" } else { projects }; - format!("\x1b[2J\x1b[H{title}\r\n\r\n{available}:\r\n • {projects}\r\n\r\n{quit}\r\n") -} - #[cfg(test)] mod tests { use super::*; use crate::auth::ClientKeyMaterial; use crate::client::{DesktopClient, RemoteTarget}; use bds_core::db::Database; + use bds_core::engine; + use russh::ChannelMsg; + use russh::client; + use russh::keys::key::PrivateKeyWithHashAlg; + use russh::keys::{PublicKey, load_secret_key}; use std::fs; use std::thread; + struct AcceptHostKey; + + impl client::Handler for AcceptHostKey { + type Error = anyhow::Error; + async fn check_server_key(&mut self, _key: &PublicKey) -> Result { + Ok(true) + } + } + #[test] fn defaults_to_loopback_and_external_binding_requires_an_explicit_value() { let config = ServerConfig::local("db".into(), "data".into()); @@ -400,15 +401,6 @@ mod tests { assert_eq!(config.port, 2222); } - #[test] - fn terminal_banner_uses_the_server_locale_and_only_terminal_control_bytes() { - let banner = terminal_banner("de", "Blog"); - assert!(banner.starts_with("\x1b[2J\x1b[H")); - assert!(banner.contains("RuDS-Serversitzung")); - assert!(banner.contains("Blog")); - assert!(!banner.contains('{')); - } - #[test] fn real_ssh_authentication_revocation_reconnect_events_and_shutdown() { let root = tempfile::tempdir().unwrap(); @@ -512,6 +504,112 @@ mod tests { first.disconnect().unwrap(); } + #[test] + fn real_ssh_pty_renders_resizes_exits_cleanly_and_reconnects() { + let root = tempfile::tempdir().unwrap(); + let server_data = root.path().join("server"); + let client_data = root.path().join("client"); + fs::create_dir_all(&server_data).unwrap(); + let database_path = server_data.join("bds.db"); + let db = Database::open(&database_path).unwrap(); + db.migrate().unwrap(); + let project_dir = root.path().join("blog"); + let project = engine::project::create_project( + db.conn(), + "PTY Blog", + Some(project_dir.to_str().unwrap()), + ) + .unwrap(); + engine::project::set_active_project(db.conn(), &project.id).unwrap(); + let runtime = ServerRuntime::start(ServerConfig { + database_path, + data_root: server_data.clone(), + bind: IpAddr::V4(Ipv4Addr::LOCALHOST), + port: 0, + mcp_port: 0, + }) + .unwrap(); + let identity = ClientKeyMaterial::ensure(&client_data).unwrap(); + fs::write( + &runtime.key_material().authorized_keys_path, + fs::read_to_string(&identity.public_key_path).unwrap(), + ) + .unwrap(); + + let tokio = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + for _ in 0..2 { + tokio.block_on(async { + let config = Arc::new(client::Config { + inactivity_timeout: Some(Duration::from_secs(5)), + ..Default::default() + }); + let mut ssh = client::connect(config, runtime.address(), AcceptHostKey) + .await + .unwrap(); + let private_key = load_secret_key(&identity.private_key_path, None).unwrap(); + let hash = ssh.best_supported_rsa_hash().await.unwrap().flatten(); + assert!( + ssh.authenticate_publickey( + "author", + PrivateKeyWithHashAlg::new(Arc::new(private_key), hash) + ) + .await + .unwrap() + .success() + ); + let mut channel = ssh.channel_open_session().await.unwrap(); + channel + .request_pty(true, "xterm-256color", 72, 18, 0, 0, &[]) + .await + .unwrap(); + channel.request_shell(true).await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(ChannelMsg::Data { data }) = channel.wait().await + && data.starts_with(b"\x1b[?25l\x1b[2J\x1b[H") + { + break data; + } + } + }) + .await + .unwrap(); + assert!( + first + .windows(b"PTY Blog".len()) + .any(|window| window == b"PTY Blog") + ); + channel.window_change(100, 30, 0, 0).await.unwrap(); + channel.data(&[17_u8][..]).await.unwrap(); + let restored = tokio::time::timeout(Duration::from_secs(3), async { + let mut output = Vec::new(); + while let Some(message) = channel.wait().await { + match message { + ChannelMsg::Data { data } => output.extend_from_slice(&data), + ChannelMsg::Close | ChannelMsg::Eof => break, + _ => {} + } + } + output + }) + .await + .unwrap(); + assert!( + restored + .windows(b"\x1b[?25h".len()) + .any(|window| window == b"\x1b[?25h") + ); + ssh.disconnect(russh::Disconnect::ByApplication, "", "en") + .await + .unwrap(); + }); + } + runtime.stop().unwrap(); + } + fn domain_event_count(messages: &[ServerMessage], project_id: &str) -> usize { messages .iter() diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs new file mode 100644 index 0000000..131fac5 --- /dev/null +++ b/crates/bds-server/src/tui.rs @@ -0,0 +1,4479 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::{Result, anyhow, bail}; +use bds_core::db::Database; +use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind}; +use bds_core::engine::{self, domain_events}; +use bds_core::i18n::{UiLocale, normalize_language}; +use bds_core::model::metadata::ProjectMetadata; +use bds_core::model::{ + DomainEntity, Media, NotificationAction, Post, PostStatus, Project, PublishingPreferences, + ScriptKind, ScriptStatus, SshMode, Tag, TemplateKind, TemplateStatus, +}; +use bds_editor::EditorBuffer; +use ratatui::Frame; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::Size; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; +use ratatui_image::picker::Picker; +use ratatui_image::protocol::Protocol; +use ratatui_image::{Image as TerminalImage, Resize}; + +use crate::host::ApplicationHost; + +const SIDEBAR_WIDTH: u16 = 30; +const MAX_DIFF_BYTES: usize = 512 * 1024; +const COLORS: &[&str] = &["", "#7aa2f7", "#9ece6a", "#e0af68", "#bb9af7", "#f7768e"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TuiView { + Posts, + Media, + Templates, + Scripts, + Tags, + Settings, + Git, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Editor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorMode { + Source, + Preview, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiKey { + Char(char), + Enter, + Esc, + Backspace, + Delete, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Tab, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TuiModifiers { + pub ctrl: bool, + pub shift: bool, + pub alt: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TuiInput { + pub key: TuiKey, + pub modifiers: TuiModifiers, +} + +impl TuiInput { + pub const fn plain(key: TuiKey) -> Self { + Self { + key, + modifiers: TuiModifiers { + ctrl: false, + shift: false, + alt: false, + }, + } + } + + pub const fn ctrl(key: TuiKey) -> Self { + Self { + key, + modifiers: TuiModifiers { + ctrl: true, + shift: false, + alt: false, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SidebarItem { + Header(String), + Post(String, String), + Media(String, String), + Template(String, String), + Script(String, String), + TagSection(TagSection), + SettingSection(SettingSection), + GitFile(String, String), + GitCommit(String, String), + Empty(String), +} + +impl SidebarItem { + fn selectable(&self) -> bool { + !matches!(self, Self::Header(_) | Self::Empty(_)) + } + + fn label(&self, locale: UiLocale) -> String { + match self { + Self::Header(value) | Self::Empty(value) => value.clone(), + Self::Post(_, value) + | Self::Media(_, value) + | Self::Template(_, value) + | Self::Script(_, value) + | Self::GitFile(_, value) + | Self::GitCommit(_, value) => value.clone(), + Self::TagSection(value) => bds_core::i18n::translate(locale, value.key()), + Self::SettingSection(value) => bds_core::i18n::translate(locale, value.key()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TagSection { + Cloud, + Manage, + Merge, +} + +impl TagSection { + const ALL: [Self; 3] = [Self::Cloud, Self::Manage, Self::Merge]; + const fn key(self) -> &'static str { + match self { + Self::Cloud => "tags.nav.cloud", + Self::Manage => "tags.nav.manage", + Self::Merge => "tags.nav.merge", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingSection { + Project, + Editor, + Content, + Ai, + Technology, + Publishing, + Data, + Mcp, + Style, +} + +impl SettingSection { + const ALL: [Self; 9] = [ + Self::Project, + Self::Editor, + Self::Content, + Self::Ai, + Self::Technology, + Self::Publishing, + Self::Data, + Self::Mcp, + Self::Style, + ]; + const fn key(self) -> &'static str { + match self { + Self::Project => "settings.nav.project", + Self::Editor => "settings.nav.editor", + Self::Content => "settings.nav.content", + Self::Ai => "settings.nav.ai", + Self::Technology => "settings.nav.technology", + Self::Publishing => "settings.nav.publishing", + Self::Data => "settings.nav.data", + Self::Mcp => "settings.nav.mcp", + Self::Style => "settings.nav.style", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FieldKind { + Text, + Bool, + Enum(&'static [&'static str]), + ReadOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SettingField { + label: String, + key: &'static str, + kind: FieldKind, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EditorEntity { + Post(String), + Template(String), + Script(String), +} + +struct Editor { + entity: EditorEntity, + title: String, + syntax: &'static str, + post_language: Option, + buffer: EditorBuffer, + mode: EditorMode, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PromptKind { + Search, + Command, + ProjectPath, + Commit, + Setting(usize), + TagCreate, + TagRename(String), + ConfirmDeleteTag(String), + EditorTitle, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Prompt { + kind: PromptKind, + value: String, + candidates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Panel { + Welcome, + MediaPreview { + title: String, + path: PathBuf, + }, + Settings(SettingSection), + Tags(TagSection), + Git, + Report { + title: String, + body: String, + action: ReportAction, + }, + Help, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReportAction { + MetadataDiff, + SiteValidation, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Overlay { + Projects { selected: usize }, + ConfirmDiscard, +} + +struct BackgroundResult { + status: String, + panel: Option, + reload: bool, +} + +pub struct TuiApp { + host: ApplicationHost, + remote: bool, + locale: UiLocale, + project: Option, + data_dir: Option, + pub view: TuiView, + pub focus: Focus, + pub selected_index: usize, + items: Vec, + editor: Option, + panel: Panel, + overlay: Option, + prompt: Option, + filters: HashMap, + status: String, + output: Vec, + settings_fields: Vec, + panel_index: usize, + marked_tags: HashSet, + scroll: u16, + quit: bool, + events: domain_events::EventSubscription, + started_task_ids: HashSet, + completed_task_ids: HashSet, + airplane: bool, + background_tx: mpsc::Sender, + background_rx: mpsc::Receiver, + image_source: Option, + image_protocol: Option, + terminal_size: (u16, u16), +} + +impl TuiApp { + pub fn new(host: ApplicationHost, remote: bool) -> Result { + let db = host.database()?; + let locale = engine::settings::ui_language(db.conn())? + .map(|value| normalize_language(&value)) + .unwrap_or_else(bds_core::i18n::detect_os_locale); + let project = engine::project::get_active_project(db.conn())?; + let data_dir = project + .as_ref() + .map(|project| host.project_data_dir(project)); + let started_task_ids = host + .tasks() + .snapshots() + .into_iter() + .map(|task| task.id) + .collect(); + let (background_tx, background_rx) = mpsc::channel(); + let mut app = Self { + host, + remote, + locale, + project, + data_dir, + view: TuiView::Posts, + focus: Focus::Sidebar, + selected_index: 0, + items: Vec::new(), + editor: None, + panel: Panel::Welcome, + overlay: None, + prompt: None, + filters: HashMap::new(), + status: String::new(), + output: Vec::new(), + settings_fields: Vec::new(), + panel_index: 0, + marked_tags: HashSet::new(), + scroll: 0, + quit: false, + events: domain_events::subscribe(), + started_task_ids, + completed_task_ids: HashSet::new(), + airplane: std::env::var_os("BDS_AIRPLANE").is_some(), + background_tx, + background_rx, + image_source: None, + image_protocol: None, + terminal_size: (80, 24), + }; + app.reload()?; + Ok(app) + } + + pub fn should_quit(&self) -> bool { + self.quit + } + pub fn locale(&self) -> UiLocale { + self.locale + } + pub fn editor_text(&self) -> Option { + self.editor.as_ref().map(|editor| editor.buffer.text()) + } + pub fn has_unsaved_changes(&self) -> bool { + self.editor + .as_ref() + .is_some_and(|editor| editor.buffer.is_dirty()) + } + + pub fn resize(&mut self, width: u16, height: u16) { + let size = (width.max(20), height.max(6)); + if size != self.terminal_size { + self.terminal_size = size; + self.rebuild_image_protocol(); + } + } + + fn rebuild_image_protocol(&mut self) { + let Some(source) = self.image_source.clone() else { + self.image_protocol = None; + return; + }; + let size = Size::new( + self.terminal_size + .0 + .saturating_sub(SIDEBAR_WIDTH + 4) + .max(4), + self.terminal_size.1.saturating_sub(6).max(2), + ); + self.image_protocol = Picker::halfblocks() + .new_protocol(source, size, Resize::Fit(None)) + .ok(); + } + + fn database(&self) -> Result { + self.host.database() + } + fn project_id(&self) -> Result<&str> { + self.project + .as_ref() + .map(|value| value.id.as_str()) + .ok_or_else(|| anyhow!("no active project")) + } + fn data_dir(&self) -> Result<&Path> { + self.data_dir + .as_deref() + .ok_or_else(|| anyhow!("no active project data folder")) + } + + pub fn poll(&mut self) -> Result<()> { + while let Ok(result) = self.background_rx.try_recv() { + self.status = result.status; + if let Some(panel) = result.panel { + self.panel = panel; + self.focus = Focus::Editor; + } + if result.reload { + self.reload()?; + } + } + for event in self.events.drain() { + if let bds_core::model::DomainEvent::SettingsChanged { key, .. } = &event + && key == engine::settings::UI_LANGUAGE_KEY + { + let db = self.database()?; + self.locale = engine::settings::ui_language(db.conn())? + .map(|v| normalize_language(&v)) + .unwrap_or(UiLocale::En); + } + if let bds_core::model::DomainEvent::EntityChanged { + project_id, + entity, + entity_id, + action, + } = &event + && self + .project + .as_ref() + .is_some_and(|project| &project.id == project_id) + { + let editing_deleted = *action == NotificationAction::Deleted + && match (&self.editor, entity) { + ( + Some(Editor { + entity: EditorEntity::Post(id), + .. + }), + DomainEntity::Post, + ) + | ( + Some(Editor { + entity: EditorEntity::Template(id), + .. + }), + DomainEntity::Template, + ) + | ( + Some(Editor { + entity: EditorEntity::Script(id), + .. + }), + DomainEntity::Script, + ) => id == entity_id, + _ => false, + }; + if editing_deleted { + self.editor = None; + self.focus = Focus::Sidebar; + self.status = "The open item was deleted by another client.".into(); + } + self.reload()?; + self.refresh_open_report()?; + } + } + self.poll_tasks(); + Ok(()) + } + + fn refresh_open_report(&mut self) -> Result<()> { + let Panel::Report { action, .. } = self.panel else { + return Ok(()); + }; + let db = self.database()?; + let data_dir = self.data_dir()?; + let project_id = self.project_id()?; + self.panel = match action { + ReportAction::MetadataDiff => Panel::Report { + title: "Metadata Diff".into(), + body: metadata_report_body(db.conn(), data_dir, project_id)?, + action, + }, + ReportAction::SiteValidation => Panel::Report { + title: "Site Validation".into(), + body: site_validation_body(db.conn(), data_dir, project_id)?, + action, + }, + }; + Ok(()) + } + + fn queue_task( + &mut self, + label: &str, + work: impl FnOnce() -> Result + Send + 'static, + ) { + let tasks = self.host.tasks(); + let task_id = tasks.submit(label); + let sender = self.background_tx.clone(); + self.started_task_ids.insert(task_id); + self.status = format!("{label} — running"); + std::thread::spawn(move || { + if !tasks.wait_until_runnable(task_id) { + return; + } + match work() { + Ok(result) => { + tasks.complete(task_id); + let _ = sender.send(result); + } + Err(error) => { + let message = error.to_string(); + tasks.fail(task_id, message.clone()); + let _ = sender.send(BackgroundResult { + status: message, + panel: None, + reload: false, + }); + } + } + }); + } + + fn poll_tasks(&mut self) { + for task in self.host.tasks().snapshots() { + if self.started_task_ids.insert(task.id) { + self.status = task.label.clone(); + } + match task.status { + engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => { + self.status = match task.progress { + Some(progress) => format!("{} — {:.0}%", task.label, progress * 100.0), + None => task.label, + }; + } + engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => { + self.status = format!("{} — complete", task.label); + } + engine::task::TaskStatus::Failed(error) + if self.completed_task_ids.insert(task.id) => + { + self.status = format!("{} — {error}", task.label); + } + engine::task::TaskStatus::Cancelled if self.completed_task_ids.insert(task.id) => { + self.status = format!("{} — cancelled", task.label); + } + _ => {} + } + } + } + + fn reload(&mut self) -> Result<()> { + self.items = self.sidebar_items()?; + self.clamp_selection(); + Ok(()) + } + + fn sidebar_items(&self) -> Result> { + let Some(project) = &self.project else { + return Ok(vec![SidebarItem::Empty(self.tr("tui.noProject"))]); + }; + let db = self.database()?; + let query = self + .filters + .get(&self.view) + .map(String::as_str) + .unwrap_or(""); + let mut items = Vec::new(); + match self.view { + TuiView::Posts => { + let posts = + bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)?; + for (heading, status) in [ + ("Drafts", PostStatus::Draft), + ("Published", PostStatus::Published), + ("Archived", PostStatus::Archived), + ] { + items.push(SidebarItem::Header(heading.into())); + items.extend( + posts + .iter() + .filter(|post| { + post.status == status && matches_filter_post(post, query) + }) + .map(|post| SidebarItem::Post(post.id.clone(), post.title.clone())), + ); + } + } + TuiView::Media => { + items.push(SidebarItem::Header("Media".into())); + items.extend( + bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)? + .into_iter() + .filter(|media| matches_filter_media(media, query)) + .map(|media| { + SidebarItem::Media( + media.id, + media.title.unwrap_or_else(|| media.filename.clone()), + ) + }), + ); + } + TuiView::Templates => { + items.push(SidebarItem::Header("Templates".into())); + items.extend( + bds_core::db::queries::template::list_templates_by_project( + db.conn(), + &project.id, + )? + .into_iter() + .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query)) + .map(|item| SidebarItem::Template(item.id, item.title)), + ); + } + TuiView::Scripts => { + items.push(SidebarItem::Header("Scripts".into())); + items.extend( + bds_core::db::queries::script::list_scripts_by_project(db.conn(), &project.id)? + .into_iter() + .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query)) + .map(|item| SidebarItem::Script(item.id, item.title)), + ); + } + TuiView::Tags => { + items.push(SidebarItem::Header("Tags".into())); + items.extend(TagSection::ALL.into_iter().map(SidebarItem::TagSection)); + } + TuiView::Settings => { + items.push(SidebarItem::Header("Settings".into())); + items.extend( + SettingSection::ALL + .into_iter() + .map(SidebarItem::SettingSection), + ); + } + TuiView::Git => return self.git_items(), + } + if !items.iter().any(SidebarItem::selectable) { + items.push(SidebarItem::Empty(self.tr("tui.noMatchingItems"))); + } + Ok(items) + } + + fn clamp_selection(&mut self) { + if self.items.is_empty() { + self.selected_index = 0; + return; + } + self.selected_index = self.selected_index.min(self.items.len() - 1); + if !self.items[self.selected_index].selectable() { + self.selected_index = self + .items + .iter() + .position(SidebarItem::selectable) + .unwrap_or(0); + } + } +} + +fn contains_ci(haystack: &str, needle: &str) -> bool { + haystack.to_lowercase().contains(&needle.to_lowercase()) +} + +fn matches_filter_post(post: &Post, query: &str) -> bool { + query.split_whitespace().all(|token| { + if let Some(tag) = token.strip_prefix("tag:") { + post.tags + .iter() + .any(|value| value.eq_ignore_ascii_case(tag)) + } else if let Some(category) = token.strip_prefix("category:") { + post.categories + .iter() + .any(|value| value.eq_ignore_ascii_case(category)) + } else if token.len() == 10 + && token.as_bytes().get(4) == Some(&b'-') + && token.as_bytes().get(7) == Some(&b'-') + { + chrono::DateTime::from_timestamp_millis(post.created_at) + .is_some_and(|date| date.format("%Y-%m-%d").to_string() == token) + } else { + contains_ci( + &format!( + "{} {} {}", + post.title, + post.slug, + post.excerpt.as_deref().unwrap_or("") + ), + token, + ) + } + }) +} + +fn matches_filter_media(media: &Media, query: &str) -> bool { + query.split_whitespace().all(|token| { + contains_ci( + &format!( + "{} {} {}", + media.title.as_deref().unwrap_or(""), + media.filename, + media.alt.as_deref().unwrap_or("") + ), + token, + ) + }) +} + +impl TuiApp { + pub fn handle_input(&mut self, input: TuiInput) -> Result<()> { + if let Err(error) = self.handle_input_inner(input) { + self.status = error.to_string(); + } + Ok(()) + } + + fn handle_input_inner(&mut self, input: TuiInput) -> Result<()> { + if self.prompt.is_some() { + return self.handle_prompt(input); + } + if self.overlay.is_some() { + return self.handle_overlay(input); + } + if input.modifiers.ctrl { + return match input.key { + TuiKey::Char('s') => self.save(false), + TuiKey::Char('p') => self.save(true), + TuiKey::Char('e') => { + self.toggle_preview(); + Ok(()) + } + TuiKey::Char('g') => self.ai_quick_action(), + TuiKey::Char('u') => self.unpublish(), + TuiKey::Char('t') => self.edit_title(), + TuiKey::Char('l') => self.cycle_editor_language(), + TuiKey::Char('q') => { + self.request_quit(); + Ok(()) + } + _ => self.handle_editor_input(input), + }; + } + if self.focus == Focus::Editor && self.editor.is_none() { + return self.handle_panel_input(input); + } + if self.focus == Focus::Editor { + return self.handle_editor_input(input); + } + match input.key { + TuiKey::Char('q') => self.request_quit(), + TuiKey::Char('j') | TuiKey::Down => self.move_selection(1), + TuiKey::Char('k') | TuiKey::Up => self.move_selection(-1), + TuiKey::Enter => self.open_selected()?, + TuiKey::Char('n') => self.new_item()?, + TuiKey::Char('/') => { + self.prompt = Some(Prompt { + kind: PromptKind::Search, + value: self.filters.get(&self.view).cloned().unwrap_or_default(), + candidates: Vec::new(), + }) + } + TuiKey::Char(':') => { + self.prompt = Some(Prompt { + kind: PromptKind::Command, + value: String::new(), + candidates: command_names().iter().map(ToString::to_string).collect(), + }) + } + TuiKey::Char('p') => self.overlay = Some(Overlay::Projects { selected: 0 }), + TuiKey::Char('o') => { + self.prompt = Some(Prompt { + kind: PromptKind::ProjectPath, + value: String::new(), + candidates: Vec::new(), + }) + } + TuiKey::Char('1') => self.set_view(TuiView::Posts)?, + TuiKey::Char('2') => self.set_view(TuiView::Media)?, + TuiKey::Char('3') => self.set_view(TuiView::Templates)?, + TuiKey::Char('4') => self.set_view(TuiView::Scripts)?, + TuiKey::Char('5') => self.set_view(TuiView::Tags)?, + TuiKey::Char('6') => self.set_view(TuiView::Settings)?, + TuiKey::Char('7') => self.set_view(TuiView::Git)?, + TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10), + TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10), + TuiKey::Char('c') if self.view == TuiView::Git => { + self.prompt = Some(Prompt { + kind: PromptKind::Commit, + value: String::new(), + candidates: Vec::new(), + }) + } + TuiKey::Char('u') if self.view == TuiView::Git => self.git_pull()?, + TuiKey::Char('s') if self.view == TuiView::Git => self.git_push()?, + TuiKey::Char(' ') if self.view == TuiView::Tags => self.toggle_tag_mark(), + TuiKey::Char('m') if self.view == TuiView::Tags => self.merge_marked_tags()?, + TuiKey::Char('d') if self.view == TuiView::Tags => self.begin_tag_delete()?, + TuiKey::Char('c') if self.view == TuiView::Tags => self.cycle_tag_color()?, + TuiKey::Char('t') if self.view == TuiView::Tags => self.cycle_tag_template()?, + TuiKey::Char('s') if self.view == TuiView::Tags => self.sync_tags()?, + TuiKey::Esc => { + self.panel = Panel::Welcome; + self.scroll = 0; + } + _ => {} + } + Ok(()) + } + + fn request_quit(&mut self) { + if self.has_unsaved_changes() { + self.overlay = Some(Overlay::ConfirmDiscard); + } else { + self.quit = true; + } + } + + fn set_view(&mut self, view: TuiView) -> Result<()> { + self.view = view; + self.focus = Focus::Sidebar; + self.selected_index = 0; + self.editor = None; + self.image_source = None; + self.image_protocol = None; + self.panel = match view { + TuiView::Tags => Panel::Tags(TagSection::Cloud), + TuiView::Settings => Panel::Settings(SettingSection::Project), + TuiView::Git => Panel::Git, + _ => Panel::Welcome, + }; + self.reload() + } + + fn move_selection(&mut self, delta: isize) { + if self.items.is_empty() { + return; + } + let mut index = self.selected_index; + for _ in 0..self.items.len() { + index = if delta > 0 { + (index + 1) % self.items.len() + } else { + index.checked_sub(1).unwrap_or(self.items.len() - 1) + }; + if self.items[index].selectable() { + self.selected_index = index; + break; + } + } + } + + fn open_selected(&mut self) -> Result<()> { + let Some(item) = self.items.get(self.selected_index).cloned() else { + return Ok(()); + }; + match item { + SidebarItem::Post(id, _) => self.open_post(&id), + SidebarItem::Template(id, _) => self.open_template(&id), + SidebarItem::Script(id, _) => self.open_script(&id), + SidebarItem::Media(id, _) => self.open_media(&id), + SidebarItem::TagSection(section) => { + self.panel = Panel::Tags(section); + self.panel_index = 0; + self.focus = Focus::Editor; + Ok(()) + } + SidebarItem::SettingSection(section) => self.open_settings(section), + SidebarItem::GitFile(path, _) => self.open_git_file(&path), + SidebarItem::GitCommit(hash, _) => self.open_git_commit(&hash), + _ => Ok(()), + } + } + + fn new_item(&mut self) -> Result<()> { + let db = self.database()?; + let project_id = self.project_id()?.to_owned(); + let entity = match self.view { + TuiView::Posts => EditorEntity::Post( + engine::post::create_post( + db.conn(), + self.data_dir()?, + &project_id, + "Untitled", + Some(""), + Vec::new(), + Vec::new(), + None, + None, + None, + )? + .id, + ), + TuiView::Templates => EditorEntity::Template( + engine::template::create_template( + db.conn(), + &project_id, + "Untitled", + TemplateKind::Post, + "", + )? + .id, + ), + TuiView::Scripts => EditorEntity::Script( + engine::script::create_script( + db.conn(), + &project_id, + "Untitled", + ScriptKind::Utility, + "function main()\nend\n", + Some("main"), + )? + .id, + ), + TuiView::Tags => { + self.prompt = Some(Prompt { + kind: PromptKind::TagCreate, + value: String::new(), + candidates: Vec::new(), + }); + return Ok(()); + } + _ => { + self.status = "New items are not available in this view.".into(); + return Ok(()); + } + }; + match entity { + EditorEntity::Post(id) => self.open_post(&id), + EditorEntity::Template(id) => self.open_template(&id), + EditorEntity::Script(id) => self.open_script(&id), + } + } + + fn open_post(&mut self, id: &str) -> Result<()> { + let db = self.database()?; + let post = bds_core::db::queries::post::get_post_by_id(db.conn(), id)?; + let content = if let Some(content) = post.content.clone() { + content + } else { + read_published_body(self.data_dir()?, &post.file_path, PublishedKind::Post)? + }; + self.editor = Some(editor( + EditorEntity::Post(post.id), + post.title, + "markdown", + post.language, + &content, + )); + self.focus = Focus::Editor; + Ok(()) + } + + fn open_template(&mut self, id: &str) -> Result<()> { + let db = self.database()?; + let item = bds_core::db::queries::template::get_template_by_id(db.conn(), id)?; + let content = if let Some(content) = item.content.clone() { + content + } else { + read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Template)? + }; + self.editor = Some(editor( + EditorEntity::Template(item.id), + item.title, + "html", + None, + &content, + )); + self.focus = Focus::Editor; + Ok(()) + } + + fn open_script(&mut self, id: &str) -> Result<()> { + let db = self.database()?; + let item = bds_core::db::queries::script::get_script_by_id(db.conn(), id)?; + let content = if let Some(content) = item.content.clone() { + content + } else { + read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Script)? + }; + self.editor = Some(editor( + EditorEntity::Script(item.id), + item.title, + "lua", + None, + &content, + )); + self.focus = Focus::Editor; + Ok(()) + } + + fn open_media(&mut self, id: &str) -> Result<()> { + let db = self.database()?; + let item = bds_core::db::queries::media::get_media_by_id(db.conn(), id)?; + let path = self.data_dir()?.join(item.file_path); + if item.mime_type.starts_with("image/") { + self.image_source = image::ImageReader::open(&path) + .ok() + .and_then(|reader| reader.decode().ok()); + self.rebuild_image_protocol(); + } else { + self.image_source = None; + self.image_protocol = None; + } + self.panel = Panel::MediaPreview { + title: item.title.unwrap_or(item.original_name), + path, + }; + Ok(()) + } + + fn handle_editor_input(&mut self, input: TuiInput) -> Result<()> { + let Some(editor) = self.editor.as_mut() else { + if input.key == TuiKey::Esc { + self.focus = Focus::Sidebar; + } + return Ok(()); + }; + if input.key == TuiKey::Esc { + self.focus = Focus::Sidebar; + return Ok(()); + } + if editor.mode == EditorMode::Preview { + return Ok(()); + } + let shift = input.modifiers.shift; + match input.key { + TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => { + editor.buffer.insert(&value.to_string()) + } + TuiKey::Enter => editor.buffer.insert("\n"), + TuiKey::Tab => editor.buffer.insert(" "), + TuiKey::Backspace => editor.buffer.backspace(), + TuiKey::Delete => editor.buffer.delete_forward(), + TuiKey::Up if shift => editor.buffer.select_up(), + TuiKey::Up => editor.buffer.move_up(), + TuiKey::Down if shift => editor.buffer.select_down(), + TuiKey::Down => editor.buffer.move_down(), + TuiKey::Left if shift => editor.buffer.select_left(), + TuiKey::Left => editor.buffer.move_left(), + TuiKey::Right if shift => editor.buffer.select_right(), + TuiKey::Right => editor.buffer.move_right(), + TuiKey::Home if shift => editor.buffer.select_home(), + TuiKey::Home => editor.buffer.move_home(), + TuiKey::End if shift => editor.buffer.select_end(), + TuiKey::End => editor.buffer.move_end(), + TuiKey::PageUp => editor.buffer.move_page_up(20), + TuiKey::PageDown => editor.buffer.move_page_down(20), + TuiKey::Char('z') if input.modifiers.ctrl => editor.buffer.undo(), + TuiKey::Char('y') if input.modifiers.ctrl => editor.buffer.redo(), + _ => {} + } + let visible_width = self.terminal_size.0.saturating_sub(SIDEBAR_WIDTH).max(1) as usize; + let visible_height = self.terminal_size.1.saturating_sub(3).max(1) as usize; + let (cursor_visual_line, total_visual_lines) = + visual_line_metrics(&editor.buffer, visible_width); + editor.buffer.ensure_visual_line_visible( + cursor_visual_line, + visible_height, + total_visual_lines.saturating_sub(visible_height), + ); + Ok(()) + } + + fn toggle_preview(&mut self) { + if let Some(editor) = &mut self.editor { + editor.mode = if editor.mode == EditorMode::Source { + EditorMode::Preview + } else { + EditorMode::Source + }; + self.status = if editor.mode == EditorMode::Preview { + "Preview" + } else { + "Source" + } + .into(); + } + } + + fn save(&mut self, publish: bool) -> Result<()> { + let Some(editor) = &self.editor else { + return self.save_settings(); + }; + let entity = editor.entity.clone(); + let title = editor.title.clone(); + let post_language = editor.post_language.clone(); + let content = editor.buffer.text(); + let db = self.database()?; + match entity { + EditorEntity::Post(id) => { + engine::post::update_post( + db.conn(), + self.data_dir()?, + &id, + Some(&title), + None, + None, + Some(&content), + None, + None, + None, + Some(post_language.as_deref()), + None, + None, + )?; + if publish { + engine::post::publish_post(db.conn(), self.data_dir()?, &id)?; + } + } + EditorEntity::Template(id) => { + engine::template::validate_template(&content).map_err(|error| anyhow!(error))?; + let project_id = self.project_id()?.to_owned(); + engine::template::update_template( + db.conn(), + &id, + &project_id, + Some(&title), + None, + None, + None, + Some(&content), + )?; + if publish { + let item = bds_core::db::queries::template::get_template_by_id(db.conn(), &id)?; + if item.status == TemplateStatus::Published { + engine::template::unpublish_template(db.conn(), self.data_dir()?, &id)?; + } + engine::template::publish_template(db.conn(), self.data_dir()?, &id)?; + } + } + EditorEntity::Script(id) => { + engine::script::validate_script_syntax(&content).map_err(|error| anyhow!(error))?; + let project_id = self.project_id()?.to_owned(); + engine::script::update_script( + db.conn(), + &id, + &project_id, + Some(&title), + None, + None, + None, + None, + Some(&content), + )?; + if publish { + let item = bds_core::db::queries::script::get_script_by_id(db.conn(), &id)?; + if item.status == ScriptStatus::Published { + engine::script::unpublish_script(db.conn(), self.data_dir()?, &id)?; + } + engine::script::publish_script(db.conn(), self.data_dir()?, &id)?; + } + } + } + if let Some(editor) = &mut self.editor { + editor.buffer.set_dirty(false); + } + self.status = if publish { + "Saved and published" + } else { + "Saved" + } + .into(); + self.reload() + } + + fn ai_quick_action(&mut self) -> Result<()> { + let Some(Editor { + entity: EditorEntity::Post(_), + title, + buffer, + .. + }) = &self.editor + else { + return Ok(()); + }; + let db = self.database()?; + if let Err(error) = engine::ai::active_endpoint(db.conn(), self.airplane) { + self.status = if self.airplane { + "Airplane mode: configure a local AI endpoint first.".into() + } else { + error.to_string() + }; + return Ok(()); + } + let request = engine::ai::OneShotRequest { + operation: engine::ai::OneShotOperation::AnalyzePost, + content: serde_json::json!({ "title": title, "content": buffer.text() }), + }; + let (response, _) = engine::ai::run_one_shot(db.conn(), self.airplane, &request)?; + let engine::ai::OneShotResponse::PostAnalysis(analysis) = response else { + bail!("AI returned the wrong response type"); + }; + self.output.push(format!( + "AI suggestions\nTitle: {}\nExcerpt: {}", + analysis.title, analysis.excerpt + )); + self.status = "AI suggestions added to output".into(); + Ok(()) + } + + fn unpublish(&mut self) -> Result<()> { + let Some(editor) = &self.editor else { + return Ok(()); + }; + let db = self.database()?; + match &editor.entity { + EditorEntity::Post(id) => engine::post::archive_post(db.conn(), self.data_dir()?, id)?, + EditorEntity::Template(id) => { + engine::template::unpublish_template(db.conn(), self.data_dir()?, id)?; + } + EditorEntity::Script(id) => { + engine::script::unpublish_script(db.conn(), self.data_dir()?, id)?; + } + } + self.status = "Unpublished".into(); + self.reload() + } + + fn edit_title(&mut self) -> Result<()> { + if let Some(editor) = &self.editor { + self.prompt = Some(Prompt { + kind: PromptKind::EditorTitle, + value: editor.title.clone(), + candidates: Vec::new(), + }); + } + Ok(()) + } + + fn cycle_editor_language(&mut self) -> Result<()> { + let Some(editor) = &mut self.editor else { + return Ok(()); + }; + if !matches!(editor.entity, EditorEntity::Post(_)) { + return Ok(()); + } + let mut languages = vec!["en".to_string()]; + if let Some(data_dir) = &self.data_dir + && let Ok(metadata) = engine::meta::read_project_json(data_dir) + { + if let Some(main) = metadata.main_language { + languages.insert(0, main); + } + languages.extend(metadata.blog_languages); + } + languages.sort(); + languages.dedup(); + let current = editor + .post_language + .as_deref() + .unwrap_or(languages.first().map(String::as_str).unwrap_or("en")); + let index = languages + .iter() + .position(|value| value == current) + .unwrap_or(0); + editor.post_language = Some(languages[(index + 1) % languages.len()].clone()); + editor.buffer.set_dirty(true); + self.status = format!( + "Language: {}", + editor.post_language.as_deref().unwrap_or("en") + ); + Ok(()) + } + + fn handle_panel_input(&mut self, input: TuiInput) -> Result<()> { + match input.key { + TuiKey::Esc => { + self.focus = Focus::Sidebar; + self.prompt = None; + self.panel = Panel::Welcome; + self.settings_fields.clear(); + self.scroll = 0; + } + TuiKey::Up | TuiKey::Char('k') => { + if matches!(self.panel, Panel::Settings(_)) { + self.move_setting_selection(-1); + } else { + self.panel_index = self.panel_index.saturating_sub(1); + } + } + TuiKey::Down | TuiKey::Char('j') => { + if matches!(self.panel, Panel::Settings(_)) { + self.move_setting_selection(1); + } else { + self.panel_index = self.panel_index.saturating_add(1); + } + } + TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10), + TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10), + TuiKey::Enter => match self.panel { + Panel::Settings(_) => self.edit_setting_field()?, + Panel::Tags(TagSection::Manage) => self.begin_tag_rename()?, + Panel::Report { action, .. } => self.apply_report(action)?, + _ => {} + }, + TuiKey::Char('n') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { + self.prompt = Some(Prompt { + kind: PromptKind::TagCreate, + value: String::new(), + candidates: Vec::new(), + }) + } + TuiKey::Char(' ') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => { + self.toggle_tag_mark() + } + TuiKey::Char('m') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => { + self.merge_marked_tags()? + } + TuiKey::Char('d') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { + self.begin_tag_delete()? + } + TuiKey::Char('c') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { + self.cycle_tag_color()? + } + TuiKey::Char('t') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => { + self.cycle_tag_template()? + } + TuiKey::Char('s') if matches!(self.panel, Panel::Tags(_)) => self.sync_tags()?, + _ => {} + } + let max = match self.panel { + Panel::Settings(_) => self.settings_fields.len(), + Panel::Tags(_) => self.tags().map(|tags| tags.len()).unwrap_or(0), + _ => usize::MAX, + }; + if max > 0 { + self.panel_index = self.panel_index.min(max - 1); + } else { + self.panel_index = 0; + } + Ok(()) + } + + fn move_setting_selection(&mut self, delta: isize) { + if self.settings_fields.is_empty() { + return; + } + let mut index = self.panel_index; + for _ in 0..self.settings_fields.len() { + index = if delta > 0 { + (index + 1) % self.settings_fields.len() + } else { + index + .checked_sub(1) + .unwrap_or(self.settings_fields.len() - 1) + }; + if self.settings_fields[index].kind != FieldKind::ReadOnly { + self.panel_index = index; + return; + } + } + } + + fn handle_prompt(&mut self, input: TuiInput) -> Result<()> { + let Some(mut prompt) = self.prompt.take() else { + return Ok(()); + }; + let refilter = matches!(input.key, TuiKey::Char(_) | TuiKey::Backspace); + match input.key { + TuiKey::Esc => { + if prompt.kind == PromptKind::Search { + self.filters.remove(&self.view); + self.reload()?; + } else if prompt.kind == PromptKind::ProjectPath { + self.overlay = Some(Overlay::Projects { selected: 0 }); + } + self.status.clear(); + return Ok(()); + } + TuiKey::Backspace => { + prompt.value.pop(); + } + TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => { + prompt.value.push(value); + if prompt.kind == PromptKind::ProjectPath { + prompt.candidates.clear(); + } + } + TuiKey::Tab if prompt.kind == PromptKind::ProjectPath => complete_path(&mut prompt), + TuiKey::Up if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => { + prompt.candidates.rotate_right(1) + } + TuiKey::Down if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => { + prompt.candidates.rotate_left(1) + } + TuiKey::Enter => return self.submit_prompt(prompt), + _ => {} + } + if prompt.kind == PromptKind::Search { + self.filters.insert(self.view, prompt.value.clone()); + self.reload()?; + } else if prompt.kind == PromptKind::Command && refilter { + prompt.candidates = command_names() + .iter() + .filter(|name| contains_ci(name, &prompt.value)) + .map(ToString::to_string) + .collect(); + } + self.prompt = Some(prompt); + Ok(()) + } + + fn submit_prompt(&mut self, prompt: Prompt) -> Result<()> { + match prompt.kind { + PromptKind::Search => { + self.filters.insert(self.view, prompt.value); + self.reload()?; + } + PromptKind::Command => { + let typed = prompt.value.trim_start_matches(':').trim(); + let selected = if typed == "?" || command_names().contains(&typed) { + typed + } else { + prompt + .candidates + .first() + .map(String::as_str) + .unwrap_or(typed) + }; + self.run_command(selected)?; + } + PromptKind::ProjectPath => self.open_project_path(&prompt.value)?, + PromptKind::Commit => { + if !self.require_git()? { + return Ok(()); + } else if prompt.value.trim().is_empty() { + self.status = "Commit message is required.".into(); + self.prompt = Some(prompt); + } else { + let data_dir = self.data_dir()?.to_owned(); + let message = prompt.value; + self.queue_task("Git commit", move || { + let output = engine::git::GitEngine::new(&data_dir) + .commit_all(&message)? + .output; + Ok(BackgroundResult { + status: if output.trim().is_empty() { + "Committed".into() + } else { + output + }, + panel: Some(Panel::Git), + reload: true, + }) + }); + } + } + PromptKind::Setting(index) => { + if let Some(field) = self.settings_fields.get_mut(index) { + field.value = prompt.value; + } + } + PromptKind::TagCreate => { + if prompt.value.trim().is_empty() { + self.status = "Tag name is required.".into(); + self.prompt = Some(prompt); + } else { + let db = self.database()?; + engine::tag::create_tag( + db.conn(), + self.data_dir()?, + self.project_id()?, + prompt.value.trim(), + None, + )?; + self.status = "Tag created".into(); + } + } + PromptKind::TagRename(id) => { + if prompt.value.trim().is_empty() { + self.status = "Tag name is required.".into(); + } else { + let db = self.database()?; + engine::tag::rename_tag( + db.conn(), + self.data_dir()?, + self.project_id()?, + &id, + prompt.value.trim(), + )?; + self.status = "Tag renamed".into(); + } + } + PromptKind::ConfirmDeleteTag(id) => { + if prompt.value.eq_ignore_ascii_case("y") { + let db = self.database()?; + engine::tag::delete_tag(db.conn(), self.data_dir()?, self.project_id()?, &id)?; + self.status = "Tag deleted".into(); + } else { + self.status = "Delete cancelled".into(); + } + } + PromptKind::EditorTitle => { + if prompt.value.trim().is_empty() { + self.status = "Title is required.".into(); + self.prompt = Some(prompt); + } else if let Some(editor) = &mut self.editor { + editor.title = prompt.value.trim().to_owned(); + editor.buffer.set_dirty(true); + } + } + } + Ok(()) + } + + fn handle_overlay(&mut self, input: TuiInput) -> Result<()> { + let Some(mut overlay) = self.overlay.take() else { + return Ok(()); + }; + match &mut overlay { + Overlay::ConfirmDiscard => match input.key { + TuiKey::Char('y') => self.quit = true, + TuiKey::Char('n') | TuiKey::Esc => {} + _ => self.overlay = Some(overlay), + }, + Overlay::Projects { selected } => { + let projects = engine::project::list_projects(self.database()?.conn())?; + match input.key { + TuiKey::Esc => {} + TuiKey::Up | TuiKey::Char('k') => *selected = selected.saturating_sub(1), + TuiKey::Down | TuiKey::Char('j') => { + *selected = (*selected + 1).min(projects.len().saturating_sub(1)) + } + TuiKey::Enter => { + if let Some(project) = projects.get(*selected) { + self.activate_project(project.clone())?; + return Ok(()); + } + } + TuiKey::Char('o') => { + self.prompt = Some(Prompt { + kind: PromptKind::ProjectPath, + value: String::new(), + candidates: Vec::new(), + }); + return Ok(()); + } + _ => {} + } + if !matches!(input.key, TuiKey::Esc) { + self.overlay = Some(overlay); + } + } + } + Ok(()) + } + + fn activate_project(&mut self, project: Project) -> Result<()> { + let db = self.database()?; + engine::project::set_active_project(db.conn(), &project.id)?; + self.data_dir = Some(self.host.project_data_dir(&project)); + self.project = Some(project); + self.editor = None; + self.panel = Panel::Welcome; + self.status = "Project switched".into(); + self.reload() + } + + fn open_project_path(&mut self, value: &str) -> Result<()> { + let path = expand_home(value); + if !path.is_dir() { + self.status = format!("Not a directory: {}", path.display()); + self.prompt = Some(Prompt { + kind: PromptKind::ProjectPath, + value: value.into(), + candidates: Vec::new(), + }); + return Ok(()); + } + let canonical = path.canonicalize()?; + let db = self.database()?; + if let Some(project) = + engine::project::list_projects(db.conn())? + .into_iter() + .find(|project| { + project + .data_path + .as_deref() + .map(Path::new) + .is_some_and(|existing| existing == canonical) + }) + { + return self.activate_project(project); + } + let name = canonical + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Blog"); + let project = engine::project::create_project( + db.conn(), + name, + Some( + canonical + .to_str() + .ok_or_else(|| anyhow!("project path is not valid UTF-8"))?, + ), + )?; + engine::project::set_active_project(db.conn(), &project.id)?; + let report = engine::rebuild::rebuild_from_filesystem(db.conn(), &canonical, &project.id)?; + self.output.push(format!( + "Rebuild: {} posts, {} media, {} templates, {} scripts", + report.posts_created + report.posts_updated, + report.media_created + report.media_updated, + report.templates_created + report.templates_updated, + report.scripts_created + report.scripts_updated + )); + self.activate_project(project) + } +} + +fn expand_home(value: &str) -> PathBuf { + if value == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value)); + } + if let Some(rest) = value.strip_prefix("~/") { + return dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(rest); + } + PathBuf::from(value) +} + +fn complete_path(prompt: &mut Prompt) { + let expanded = expand_home(&prompt.value); + let (directory, prefix) = if expanded.is_dir() { + (expanded.as_path(), "") + } else { + ( + expanded.parent().unwrap_or(Path::new(".")), + expanded.file_name().and_then(|v| v.to_str()).unwrap_or(""), + ) + }; + let show_dot = prefix.starts_with('.'); + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + let mut candidates = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| (show_dot || !name.starts_with('.')) && name.starts_with(prefix)) + .collect::>(); + candidates.sort(); + if candidates.is_empty() { + return; + } + let completion = longest_common_prefix(&candidates); + let parent = Path::new(&prompt.value).parent().unwrap_or(Path::new("")); + prompt.value = parent.join(completion).to_string_lossy().into_owned(); + if candidates.len() == 1 { + prompt.value.push(std::path::MAIN_SEPARATOR); + } + prompt.candidates = candidates; +} + +fn longest_common_prefix(values: &[String]) -> String { + let Some(first) = values.first() else { + return String::new(); + }; + first + .chars() + .take_while(|_| true) + .enumerate() + .take_while(|(index, value)| { + values + .iter() + .all(|candidate| candidate.chars().nth(*index) == Some(*value)) + }) + .map(|(_, value)| value) + .collect() +} + +impl TuiApp { + fn open_settings(&mut self, section: SettingSection) -> Result<()> { + self.settings_fields = self.load_setting_fields(section)?; + self.panel = Panel::Settings(section); + self.panel_index = self + .settings_fields + .iter() + .position(|field| field.kind != FieldKind::ReadOnly) + .unwrap_or(0); + self.focus = Focus::Editor; + Ok(()) + } + + fn load_setting_fields(&self, section: SettingSection) -> Result> { + let db = self.database()?; + let project = self.project.as_ref(); + let metadata = self + .data_dir() + .ok() + .and_then(|path| engine::meta::read_project_json(path).ok()); + let publishing = self + .data_dir() + .ok() + .and_then(|path| engine::meta::read_publishing_json(path).ok()) + .unwrap_or_default(); + let ai = engine::ai::load_ai_settings(db.conn(), self.airplane)?; + let specs: Vec<(&'static str, &'static str, FieldKind, String)> = match section { + SettingSection::Project => vec![ + ( + "Project name", + "project.name", + FieldKind::Text, + project.map(|p| p.name.clone()).unwrap_or_default(), + ), + ( + "Description", + "project.description", + FieldKind::Text, + project + .and_then(|p| p.description.clone()) + .unwrap_or_default(), + ), + ( + "Data folder", + "project.data_path", + FieldKind::ReadOnly, + self.data_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + ), + ( + "UI language", + engine::settings::UI_LANGUAGE_KEY, + FieldKind::Enum(&["en", "de", "fr", "it", "es"]), + self.locale.code().into(), + ), + ( + "Default author", + "meta.default_author", + FieldKind::Text, + metadata + .as_ref() + .and_then(|value| value.default_author.clone()) + .unwrap_or_default(), + ), + ( + "Posts per page", + "meta.max_posts_per_page", + FieldKind::Text, + metadata + .as_ref() + .map(|value| value.max_posts_per_page.to_string()) + .unwrap_or_else(|| "50".into()), + ), + ( + "Main language", + "meta.main_language", + FieldKind::Text, + metadata + .as_ref() + .and_then(|value| value.main_language.clone()) + .unwrap_or_default(), + ), + ( + "Blog languages", + "meta.blog_languages", + FieldKind::Text, + metadata + .as_ref() + .map(|value| value.blog_languages.join(", ")) + .unwrap_or_default(), + ), + ( + "Public URL", + "meta.public_url", + FieldKind::Text, + metadata + .as_ref() + .and_then(|value| value.public_url.clone()) + .unwrap_or_default(), + ), + ( + "Image import concurrency", + "meta.image_import_concurrency", + FieldKind::Text, + metadata + .as_ref() + .map(|value| value.image_import_concurrency.to_string()) + .unwrap_or_else(|| "4".into()), + ), + ( + "Blogmark category", + "meta.blogmark_category", + FieldKind::Text, + metadata + .as_ref() + .and_then(|value| value.blogmark_category.clone()) + .unwrap_or_default(), + ), + ], + SettingSection::Editor => field_specs(&[ + ( + "Default mode", + "editor.default_mode", + FieldKind::Enum(&["markdown", "preview"]), + "markdown", + ), + ( + "Diff style", + "editor.diff_view_style", + FieldKind::Enum(&["unified", "split"]), + "unified", + ), + ( + "Wrap long lines", + "editor.wrap_long_lines", + FieldKind::Bool, + "true", + ), + ( + "Hide unchanged", + "editor.hide_unchanged_regions", + FieldKind::Bool, + "false", + ), + ]), + SettingSection::Content => vec![ + ( + "Categories", + "meta.categories", + FieldKind::ReadOnly, + self.data_dir() + .ok() + .and_then(|path| engine::meta::read_categories_json(path).ok()) + .map(|values| values.join(", ")) + .unwrap_or_default(), + ), + ( + "Category editing", + "meta.category_editing", + FieldKind::ReadOnly, + self.tr("tui.categoryEditingGuiOnly"), + ), + ], + SettingSection::Ai => vec![ + ( + "Airplane mode", + "ai.airplane_mode", + FieldKind::Bool, + self.airplane.to_string(), + ), + ( + "Online endpoint URL", + "ai.endpoint.online.url", + FieldKind::Text, + ai.online_endpoint.url, + ), + ( + "Online endpoint model", + "ai.endpoint.online.model", + FieldKind::Text, + ai.online_endpoint.model, + ), + ( + "Online API key", + "ai.endpoint.online.api_key", + FieldKind::ReadOnly, + if ai.online_endpoint.api_key_configured { + "Configured in the system keychain".into() + } else { + "Configure in the desktop application".into() + }, + ), + ( + "Airplane endpoint URL", + "ai.endpoint.airplane.url", + FieldKind::Text, + ai.airplane_endpoint.url, + ), + ( + "Airplane endpoint model", + "ai.endpoint.airplane.model", + FieldKind::Text, + ai.airplane_endpoint.model, + ), + ( + "Default model", + "ai.default_model", + FieldKind::Text, + ai.default_model.unwrap_or_default(), + ), + ( + "Title model", + "ai.title_model", + FieldKind::Text, + ai.title_model.unwrap_or_default(), + ), + ( + "Image model", + "ai.image_model", + FieldKind::Text, + ai.image_model.unwrap_or_default(), + ), + ( + "System prompt", + "ai.system_prompt", + FieldKind::Text, + ai.system_prompt, + ), + ], + SettingSection::Technology => vec![ + ( + "Runtime", + "technology.runtime", + FieldKind::ReadOnly, + "Lua".into(), + ), + ( + "Semantic similarity", + "meta.semantic_similarity_enabled", + FieldKind::Bool, + metadata + .as_ref() + .map(|value| value.semantic_similarity_enabled.to_string()) + .unwrap_or_else(|| "false".into()), + ), + ], + SettingSection::Publishing => vec![ + ( + "SSH host", + "publishing.ssh_host", + FieldKind::Text, + publishing.ssh_host.unwrap_or_default(), + ), + ( + "SSH username", + "publishing.ssh_username", + FieldKind::Text, + publishing.ssh_user.unwrap_or_default(), + ), + ( + "Remote path", + "publishing.ssh_remote_path", + FieldKind::Text, + publishing.ssh_remote_path.unwrap_or_default(), + ), + ( + "Transfer mode", + "publishing.ssh_mode", + FieldKind::Enum(&["scp", "rsync"]), + match publishing.ssh_mode { + SshMode::Scp => "scp".into(), + SshMode::Rsync => "rsync".into(), + }, + ), + ], + SettingSection::Data => vec![ + ( + "Database", + "data.database", + FieldKind::ReadOnly, + self.host.database_path().display().to_string(), + ), + ( + "Automatic rebuild", + "data.automatic_rebuild", + FieldKind::Bool, + "true".into(), + ), + ], + SettingSection::Mcp => { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + vec![ + ( + "Enable MCP server", + "mcp.http.enabled", + FieldKind::Bool, + "false".into(), + ), + ( + "Claude Code", + "mcp.agent.claude_code", + FieldKind::Bool, + engine::mcp::is_agent_configured(engine::mcp::McpAgent::ClaudeCode, &home) + .to_string(), + ), + ( + "GitHub Copilot", + "mcp.agent.github_copilot", + FieldKind::Bool, + engine::mcp::is_agent_configured( + engine::mcp::McpAgent::GithubCopilot, + &home, + ) + .to_string(), + ), + ( + "Pending proposals", + "mcp.proposals", + FieldKind::ReadOnly, + engine::mcp::list_pending_proposals(db.conn(), self.project_id()?)? + .len() + .to_string(), + ), + ] + } + SettingSection::Style => field_specs(&[ + ( + "Theme", + "style.theme", + FieldKind::Enum(&["system", "light", "dark"]), + "system", + ), + ( + "Content width", + "style.content_width", + FieldKind::Text, + "72", + ), + ]), + }; + specs + .into_iter() + .map(|(label, key, kind, fallback)| { + let value = if kind == FieldKind::ReadOnly + || key.starts_with("project.") + || key.starts_with("meta.") + || key.starts_with("publishing.") + || key.starts_with("ai.endpoint.") + || key.starts_with("mcp.agent.") + || matches!( + key, + "ai.airplane_mode" + | "ai.default_model" + | "ai.title_model" + | "ai.image_model" + | "ai.system_prompt" + ) { + fallback + } else { + engine::settings::get(db.conn(), key)?.unwrap_or(fallback) + }; + Ok(SettingField { + label: setting_field_label(self.locale, key, label), + key, + kind, + value, + }) + }) + .collect() + } + + fn edit_setting_field(&mut self) -> Result<()> { + let Some(field) = self.settings_fields.get_mut(self.panel_index) else { + return Ok(()); + }; + match &field.kind { + FieldKind::ReadOnly => self.status = "This value is read-only.".into(), + FieldKind::Bool => { + field.value = (!field.value.eq_ignore_ascii_case("true")).to_string() + } + FieldKind::Enum(values) => { + let index = values + .iter() + .position(|value| *value == field.value) + .unwrap_or(0); + field.value = values[(index + 1) % values.len()].to_string(); + } + FieldKind::Text => { + self.prompt = Some(Prompt { + kind: PromptKind::Setting(self.panel_index), + value: field.value.clone(), + candidates: Vec::new(), + }) + } + } + Ok(()) + } + + fn save_settings(&mut self) -> Result<()> { + let Panel::Settings(section) = self.panel else { + return Ok(()); + }; + let db = self.database()?; + match section { + SettingSection::Project => { + let name = self.setting_value("project.name").trim(); + if name.is_empty() { + bail!("project name must not be empty"); + } + let mut project = self.project.clone().ok_or_else(|| anyhow!("no project"))?; + project.name = name.into(); + project.description = optional_string(self.setting_value("project.description")); + project.updated_at = bds_core::util::now_unix_ms(); + let mut metadata = self.project_metadata()?; + metadata.name = project.name.clone(); + metadata.description = project.description.clone(); + metadata.default_author = + optional_string(self.setting_value("meta.default_author")); + metadata.main_language = optional_string(self.setting_value("meta.main_language")); + metadata.public_url = optional_string(self.setting_value("meta.public_url")); + metadata.blogmark_category = + optional_string(self.setting_value("meta.blogmark_category")); + metadata.blog_languages = self + .setting_value("meta.blog_languages") + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + metadata.blog_languages.sort(); + metadata.blog_languages.dedup(); + metadata.max_posts_per_page = self + .setting_value("meta.max_posts_per_page") + .trim() + .parse() + .map_err(|_| anyhow!("posts per page must be a number from 1 to 500"))?; + metadata.image_import_concurrency = self + .setting_value("meta.image_import_concurrency") + .trim() + .parse() + .map_err(|_| { + anyhow!("image import concurrency must be a number from 1 to 8") + })?; + metadata.validate().map_err(|error| anyhow!(error))?; + bds_core::db::queries::project::update_project(db.conn(), &project)?; + engine::meta::write_project_json(self.data_dir()?, &metadata)?; + self.project = Some(project); + if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) { + engine::settings::set(db.conn(), value.key, &value.value)?; + self.locale = normalize_language(&value.value); + } + } + SettingSection::Content => {} + SettingSection::Ai => { + self.save_ai_settings(&db)?; + } + SettingSection::Technology => { + let mut metadata = self.project_metadata()?; + metadata.semantic_similarity_enabled = self + .setting_value("meta.semantic_similarity_enabled") + .eq_ignore_ascii_case("true"); + engine::meta::write_project_json(self.data_dir()?, &metadata)?; + } + SettingSection::Publishing => { + let preferences = PublishingPreferences { + ssh_host: optional_string(self.setting_value("publishing.ssh_host")), + ssh_user: optional_string(self.setting_value("publishing.ssh_username")), + ssh_remote_path: optional_string( + self.setting_value("publishing.ssh_remote_path"), + ), + ssh_mode: if self.setting_value("publishing.ssh_mode") == "rsync" { + SshMode::Rsync + } else { + SshMode::Scp + }, + }; + engine::meta::write_publishing_json(self.data_dir()?, &preferences)?; + } + SettingSection::Mcp => { + engine::settings::set( + db.conn(), + "mcp.http.enabled", + self.setting_value("mcp.http.enabled"), + )?; + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + for (agent, key) in [ + (engine::mcp::McpAgent::ClaudeCode, "mcp.agent.claude_code"), + ( + engine::mcp::McpAgent::GithubCopilot, + "mcp.agent.github_copilot", + ), + ] { + let desired = self.setting_value(key).eq_ignore_ascii_case("true"); + let configured = engine::mcp::is_agent_configured(agent, &home); + if desired && !configured { + let executable = engine::mcp::packaged_mcp_executable()?; + engine::mcp::install_agent_config(agent, &home, &executable)?; + } else if !desired && configured { + engine::mcp::remove_agent_config(agent, &home)?; + } + } + } + _ => { + for field in &self.settings_fields { + if field.kind != FieldKind::ReadOnly { + engine::settings::set(db.conn(), field.key, &field.value)?; + } + } + } + } + if let Some(value) = self + .settings_fields + .iter() + .find(|field| field.key == "ai.airplane_mode") + { + self.airplane = value.value == "true"; + } + if let Some(value) = self + .settings_fields + .iter() + .find(|field| field.key == engine::settings::UI_LANGUAGE_KEY) + { + self.locale = normalize_language(&value.value); + } + let selected_key = self + .settings_fields + .get(self.panel_index) + .map(|field| field.key); + self.settings_fields = self.load_setting_fields(section)?; + self.panel_index = selected_key + .and_then(|key| { + self.settings_fields + .iter() + .position(|field| field.key == key) + }) + .filter(|index| self.settings_fields[*index].kind != FieldKind::ReadOnly) + .or_else(|| { + self.settings_fields + .iter() + .position(|field| field.kind != FieldKind::ReadOnly) + }) + .unwrap_or(0); + self.status = "Settings saved".into(); + Ok(()) + } + + fn setting_field(&self, key: &str) -> Option<&SettingField> { + self.settings_fields.iter().find(|field| field.key == key) + } + + fn setting_value(&self, key: &str) -> &str { + self.setting_field(key) + .map(|field| field.value.as_str()) + .unwrap_or("") + } + + fn project_metadata(&self) -> Result { + engine::meta::read_project_json(self.data_dir()?).or_else(|_| { + let project = self.project.as_ref().ok_or_else(|| anyhow!("no project"))?; + Ok(ProjectMetadata { + name: project.name.clone(), + description: project.description.clone(), + public_url: None, + main_language: None, + default_author: None, + max_posts_per_page: 50, + image_import_concurrency: 4, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: Vec::new(), + }) + }) + } + + fn save_ai_settings(&mut self, db: &Database) -> Result<()> { + for (kind, url_key, model_key) in [ + ( + AiEndpointKind::Online, + "ai.endpoint.online.url", + "ai.endpoint.online.model", + ), + ( + AiEndpointKind::Airplane, + "ai.endpoint.airplane.url", + "ai.endpoint.airplane.model", + ), + ] { + let url = self.setting_value(url_key).trim(); + let model = self.setting_value(model_key).trim(); + if !url.is_empty() || !model.is_empty() { + engine::ai::save_endpoint( + db.conn(), + &AiEndpointConfig { + kind, + url: url.into(), + model: model.into(), + api_key: if kind == AiEndpointKind::Online { + engine::ai::load_endpoint_api_key(kind)? + } else { + None + }, + }, + )?; + } + } + engine::ai::save_model_preferences( + db.conn(), + optional_str(self.setting_value("ai.default_model")), + optional_str(self.setting_value("ai.title_model")), + optional_str(self.setting_value("ai.image_model")), + self.setting_value("ai.system_prompt"), + )?; + self.airplane = self + .setting_value("ai.airplane_mode") + .eq_ignore_ascii_case("true"); + Ok(()) + } + + fn tags(&self) -> Result> { + let db = self.database()?; + let mut tags = + bds_core::db::queries::tag::list_tags_by_project(db.conn(), self.project_id()?)?; + tags.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(tags) + } + + fn begin_tag_rename(&mut self) -> Result<()> { + if let Some(tag) = self.tags()?.get(self.panel_index) { + self.prompt = Some(Prompt { + kind: PromptKind::TagRename(tag.id.clone()), + value: tag.name.clone(), + candidates: Vec::new(), + }); + } + Ok(()) + } + + fn begin_tag_delete(&mut self) -> Result<()> { + if let Some(tag) = self.tags()?.get(self.panel_index) { + let db = self.database()?; + let count = + bds_core::db::queries::post::list_posts_by_project(db.conn(), self.project_id()?)? + .into_iter() + .filter(|post| { + post.tags + .iter() + .any(|name| name.eq_ignore_ascii_case(&tag.name)) + }) + .count(); + self.status = format!( + "Delete '{}' used by {count} post(s)? Type y and Enter.", + tag.name + ); + self.prompt = Some(Prompt { + kind: PromptKind::ConfirmDeleteTag(tag.id.clone()), + value: String::new(), + candidates: Vec::new(), + }); + } + Ok(()) + } + + fn toggle_tag_mark(&mut self) { + if let Ok(tags) = self.tags() + && let Some(tag) = tags.get(self.panel_index) + { + if !self.marked_tags.remove(&tag.id) { + self.marked_tags.insert(tag.id.clone()); + } + } + } + + fn merge_marked_tags(&mut self) -> Result<()> { + let tags = self.tags()?; + let Some(target) = tags.get(self.panel_index) else { + return Ok(()); + }; + if !self.marked_tags.contains(&target.id) || self.marked_tags.len() < 2 { + self.status = "Mark at least two tags, including the target.".into(); + return Ok(()); + } + let sources = self + .marked_tags + .iter() + .filter(|id| *id != &target.id) + .map(String::as_str) + .collect::>(); + let db = self.database()?; + engine::tag::merge_tags( + db.conn(), + self.data_dir()?, + self.project_id()?, + &sources, + &target.id, + )?; + self.marked_tags.clear(); + self.status = "Tags merged".into(); + Ok(()) + } + + fn cycle_tag_color(&mut self) -> Result<()> { + let Some(tag) = self.tags()?.get(self.panel_index).cloned() else { + return Ok(()); + }; + let index = COLORS + .iter() + .position(|color| Some(*color) == tag.color.as_deref()) + .unwrap_or(0); + let db = self.database()?; + engine::tag::update_tag( + db.conn(), + self.data_dir()?, + &tag.id, + None, + Some(COLORS[(index + 1) % COLORS.len()]), + None, + )?; + self.status = "Tag colour updated".into(); + Ok(()) + } + + fn cycle_tag_template(&mut self) -> Result<()> { + let Some(tag) = self.tags()?.get(self.panel_index).cloned() else { + return Ok(()); + }; + let db = self.database()?; + let mut templates = bds_core::db::queries::template::list_templates_by_project( + db.conn(), + self.project_id()?, + )? + .into_iter() + .filter(|template| template.kind == TemplateKind::Post) + .map(|template| template.slug) + .collect::>(); + templates.insert(0, String::new()); + let index = templates + .iter() + .position(|slug| Some(slug.as_str()) == tag.post_template_slug.as_deref()) + .unwrap_or(0); + engine::tag::update_tag( + db.conn(), + self.data_dir()?, + &tag.id, + None, + None, + Some(&templates[(index + 1) % templates.len()]), + )?; + self.status = "Tag template updated".into(); + Ok(()) + } + + fn sync_tags(&mut self) -> Result<()> { + let db = self.database()?; + let tags = engine::tag::sync_tags_from_posts(db.conn(), self.project_id()?)?; + engine::tag::rewrite_tags_json(db.conn(), self.data_dir()?, self.project_id()?)?; + self.status = format!("Synchronized {} tags", tags.len()); + Ok(()) + } +} + +fn field_specs( + values: &[(&'static str, &'static str, FieldKind, &'static str)], +) -> Vec<(&'static str, &'static str, FieldKind, String)> { + values + .iter() + .map(|(label, key, kind, value)| (*label, *key, kind.clone(), (*value).into())) + .collect() +} + +fn setting_field_label(locale: UiLocale, key: &str, fallback: &str) -> String { + let translation_key = match key { + "project.name" => "settings.projectName", + "project.description" => "settings.projectDescription", + "project.data_path" => "settings.dataPath", + engine::settings::UI_LANGUAGE_KEY => "tui.settingUiLanguage", + "editor.default_mode" => "settings.defaultMode", + "editor.diff_view_style" => "settings.diffViewStyle", + "editor.wrap_long_lines" => "settings.wrapLongLines", + "editor.hide_unchanged_regions" => "settings.hideUnchangedRegions", + "meta.default_author" => "settings.defaultAuthor", + "meta.max_posts_per_page" => "settings.maxPostsPerPage", + "meta.main_language" => "settings.mainLanguage", + "meta.blog_languages" => "settings.blogLanguages", + "meta.public_url" => "settings.publicUrl", + "meta.image_import_concurrency" => "settings.imageImportConcurrency", + "meta.blogmark_category" => "settings.blogmarkCategory", + "meta.categories" => "editor.categories", + "meta.category_editing" => "tui.categoryEditing", + "ai.airplane_mode" => "settings.offlineMode", + "ai.endpoint.online.url" => "settings.onlineEndpointUrl", + "ai.endpoint.online.model" => "settings.onlineEndpointModel", + "ai.endpoint.online.api_key" => "settings.onlineApiKey", + "ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl", + "ai.endpoint.airplane.model" => "settings.airplaneEndpointModel", + "ai.default_model" => "settings.defaultModel", + "ai.title_model" => "settings.titleModel", + "ai.image_model" => "settings.imageAnalysisModel", + "ai.system_prompt" => "settings.systemPrompt", + "technology.runtime" => "tui.settingRuntime", + "meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled", + "publishing.ssh_host" => "settings.sshHost", + "publishing.ssh_username" => "settings.sshUsername", + "publishing.ssh_remote_path" => "settings.sshRemotePath", + "publishing.ssh_mode" => "tui.settingTransferMode", + "data.database" => "tui.settingDatabase", + "data.automatic_rebuild" => "tui.settingAutomaticRebuild", + "mcp.http.enabled" => "settings.mcpEnable", + "mcp.proposals" => "settings.mcpProposals", + "style.theme" => "tui.settingTheme", + "style.content_width" => "tui.settingContentWidth", + _ => return fallback.into(), + }; + bds_core::i18n::translate(locale, translation_key) +} + +fn optional_string(value: &str) -> Option { + optional_str(value).map(ToOwned::to_owned) +} + +fn optional_str(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +fn visual_line_metrics(buffer: &EditorBuffer, width: usize) -> (usize, usize) { + let width = width.max(1); + let (cursor_line, cursor_col) = buffer.cursor(); + let mut visual_cursor = 0; + let mut visual_total = 0; + for index in 0..buffer.line_count() { + let line_len = buffer + .line(index) + .map(|line| line.chars().filter(|character| *character != '\n').count()) + .unwrap_or(0); + if index < cursor_line { + visual_cursor += line_len.max(1).div_ceil(width); + } else if index == cursor_line { + visual_cursor += cursor_col / width; + } + visual_total += line_len.max(1).div_ceil(width); + } + (visual_cursor, visual_total.max(1)) +} + +impl TuiApp { + fn git_items(&self) -> Result> { + let Some(data_dir) = &self.data_dir else { + return Ok(vec![SidebarItem::Empty("No active project".into())]); + }; + let git = engine::git::GitEngine::new(data_dir); + let repository = git.repository()?; + if !repository.is_initialized { + return Ok(vec![SidebarItem::Empty(self.tr("git.notRepository"))]); + } + let remote = git.remote_state()?; + let branch = remote + .local_branch + .clone() + .unwrap_or_else(|| "detached".into()); + let mut items = vec![SidebarItem::Header(format!( + "{branch} ↑{} ↓{}", + remote.ahead, remote.behind + ))]; + items.extend(git.status()?.into_iter().map(|file| { + SidebarItem::GitFile( + file.path.clone(), + format!("{} {}", file.kind.code(), file.path), + ) + })); + items.push(SidebarItem::Header("History".into())); + items.extend(git.history(&branch)?.into_iter().map(|commit| { + let marker = match commit.sync_status { + engine::git::SyncStatus::LocalOnly => "↑", + engine::git::SyncStatus::RemoteOnly => "↓", + engine::git::SyncStatus::Both => " ", + }; + let short = commit.hash.chars().take(8).collect::(); + SidebarItem::GitCommit( + commit.hash, + format!("{marker} {short} {}", commit.subject.unwrap_or_default()), + ) + })); + if !items.iter().any(SidebarItem::selectable) { + items.push(SidebarItem::Empty(self.tr("tui.workingTreeClean"))); + } + Ok(items) + } + + fn open_git_file(&mut self, path: &str) -> Result<()> { + let patch = engine::git::GitEngine::new(self.data_dir()?) + .file_diff(path)? + .patch; + self.output = vec![truncate_bytes(&patch, MAX_DIFF_BYTES)]; + self.panel = Panel::Git; + self.scroll = 0; + Ok(()) + } + + fn open_git_commit(&mut self, hash: &str) -> Result<()> { + let diff = engine::git::GitEngine::new(self.data_dir()?).commit_diff(hash)?; + self.output = vec![truncate_bytes(&diff, MAX_DIFF_BYTES)]; + self.panel = Panel::Git; + self.scroll = 0; + Ok(()) + } + + fn git_pull(&mut self) -> Result<()> { + if !self.require_git()? { + return Ok(()); + } + if self.airplane { + self.status = "Airplane mode blocks Git pull.".into(); + return Ok(()); + } + let data_dir = self.data_dir()?.to_owned(); + self.queue_task("Git pull", move || { + let mut output = Vec::new(); + let result = engine::git::GitEngine::new(&data_dir) + .pull(|| false, |line| output.push(line.text))?; + output.push(result.output); + Ok(BackgroundResult { + status: format!("Pull complete\n{}", output.join("\n")), + panel: Some(Panel::Git), + reload: true, + }) + }); + Ok(()) + } + + fn git_push(&mut self) -> Result<()> { + if !self.require_git()? { + return Ok(()); + } + if self.airplane { + self.status = "Airplane mode blocks Git push.".into(); + return Ok(()); + } + let data_dir = self.data_dir()?.to_owned(); + self.queue_task("Git push", move || { + let mut output = Vec::new(); + let result = engine::git::GitEngine::new(&data_dir) + .push(|| false, |line| output.push(line.text))?; + output.push(result.output); + Ok(BackgroundResult { + status: format!("Push complete\n{}", output.join("\n")), + panel: Some(Panel::Git), + reload: true, + }) + }); + Ok(()) + } + + fn require_git(&mut self) -> Result { + let ready = engine::git::GitEngine::new(self.data_dir()?) + .repository()? + .is_initialized; + if !ready { + self.status = self.tr("git.notRepository"); + } + Ok(ready) + } + + fn run_command(&mut self, command: &str) -> Result<()> { + let command = if command.is_empty() { + command_names().first().copied().unwrap_or("") + } else { + command + }; + if command == "?" { + self.panel = Panel::Help; + self.focus = Focus::Editor; + return Ok(()); + } + let project_id = self.project_id()?.to_owned(); + let data_dir = self.data_dir()?.to_owned(); + let database_path = self.host.database_path().to_owned(); + match command { + "metadata diff" => { + self.queue_task("Metadata diff", move || { + let db = Database::open(&database_path)?; + Ok(BackgroundResult { + status: "Metadata diff complete".into(), + panel: Some(Panel::Report { + title: "Metadata Diff".into(), + body: metadata_report_body(db.conn(), &data_dir, &project_id)?, + action: ReportAction::MetadataDiff, + }), + reload: false, + }) + }); + } + "validate site" => { + self.queue_task("Validate site", move || { + let db = Database::open(&database_path)?; + Ok(BackgroundResult { + status: "Site validation complete".into(), + panel: Some(Panel::Report { + title: "Site Validation".into(), + body: site_validation_body(db.conn(), &data_dir, &project_id)?, + action: ReportAction::SiteValidation, + }), + reload: false, + }) + }); + } + "force render" => { + self.queue_task("Force render", move || { + let db = Database::open(&database_path)?; + let metadata = engine::meta::read_project_json(&data_dir)?; + let posts = published_sources(db.conn(), &data_dir, &project_id)?; + let report = engine::generation::generate_starter_site( + db.conn(), + &data_dir.join("html"), + &project_id, + &metadata, + &posts, + metadata.main_language.as_deref().unwrap_or("en"), + )?; + Ok(BackgroundResult { + status: format!("Rendered {} files", report.written_paths.len()), + panel: None, + reload: false, + }) + }); + } + "rebuild database" => { + self.queue_task("Rebuild database", move || { + let db = Database::open(&database_path)?; + let report = engine::rebuild::rebuild_from_filesystem( + db.conn(), + &data_dir, + &project_id, + )?; + Ok(BackgroundResult { + status: format!( + "Rebuilt {} items", + report.posts_created + + report.posts_updated + + report.media_created + + report.media_updated + + report.templates_created + + report.templates_updated + + report.scripts_created + + report.scripts_updated + ), + panel: None, + reload: true, + }) + }); + } + "reindex search" => { + self.queue_task("Reindex search", move || { + let db = Database::open(&database_path)?; + let report = engine::search::reindex_project(db.conn(), &project_id, None)?; + Ok(BackgroundResult { + status: format!("Reindexed {} posts", report.posts_indexed), + panel: None, + reload: false, + }) + }); + } + "validate translations [GUI]" | "find duplicates [GUI]" => { + self.status = "This command's result editor is available in the desktop UI.".into() + } + "upload site" if self.airplane => self.status = "Airplane mode blocks upload.".into(), + "upload site" => self.status = + "Upload requires publishing credentials; use the shared Publishing settings first." + .into(), + "browser preview URL" => { + self.status = format!("file://{}", data_dir.join("html/index.html").display()) + } + _ => self.status = format!("Unknown command: {command}"), + } + Ok(()) + } + + fn apply_report(&mut self, action: ReportAction) -> Result<()> { + let db = self.database()?; + let project_id = self.project_id()?.to_owned(); + let data_dir = self.data_dir()?.to_owned(); + match action { + ReportAction::MetadataDiff => { + let report = engine::metadata_diff::compute_metadata_diff( + db.conn(), + &data_dir, + &project_id, + )?; + for item in &report.diffs { + engine::metadata_diff::repair_metadata_diff_item( + db.conn(), + &data_dir, + &project_id, + engine::metadata_diff::RepairDirection::FileToDatabase, + item, + )?; + } + for orphan in &report.orphans { + if orphan.reason == "file_without_db_entry" { + engine::metadata_diff::import_orphan_file( + db.conn(), + &data_dir, + &project_id, + orphan, + )?; + } + } + self.status = format!( + "Applied {} metadata changes", + report.diffs.len() + report.orphans.len() + ); + self.reload()?; + } + ReportAction::SiteValidation => { + let validation = + engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)?; + let metadata = engine::meta::read_project_json(&data_dir)?; + let posts = published_sources(db.conn(), &data_dir, &project_id)?; + let sections = engine::generation::sections_from_validation_report(&validation); + let report = engine::generation::apply_validation_sections( + db.conn(), + &data_dir.join("html"), + &project_id, + &metadata, + &posts, + §ions, + )?; + self.status = format!( + "Applied validation: {} written, {} removed", + report.written_paths.len(), + report.deleted_paths.len() + ); + } + } + self.panel = Panel::Welcome; + self.focus = Focus::Sidebar; + Ok(()) + } +} + +fn command_names() -> &'static [&'static str] { + &[ + "metadata diff", + "validate site", + "force render", + "rebuild database", + "reindex search", + "validate translations [GUI]", + "find duplicates [GUI]", + "upload site", + "browser preview URL", + ] +} + +fn truncate_bytes(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.into(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + format!("{}\n\n[diff truncated]", &value[..end]) +} + +fn published_sources( + conn: &bds_core::db::DbConnection, + data_dir: &Path, + project_id: &str, +) -> Result> { + let mut sources = Vec::new(); + for post in bds_core::db::queries::post::list_posts_by_project(conn, project_id)? { + if let Some(source) = engine::generation::load_published_post_source(data_dir, post)? { + sources.push(source); + } + } + Ok(sources) +} + +fn metadata_report_body( + conn: &bds_core::db::DbConnection, + data_dir: &Path, + project_id: &str, +) -> Result { + let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?; + let mut lines = report + .diffs + .iter() + .map(|item| { + format!( + "{} {}: {} field(s)", + item.entity_type, + item.file_path, + item.fields.len() + ) + }) + .collect::>(); + lines.extend( + report + .orphans + .iter() + .map(|item| format!("orphan {}: {}", item.file_path, item.reason)), + ); + lines.extend(report.errors.iter().map(|error| format!("error: {error}"))); + if lines.is_empty() { + lines.push("No metadata differences".into()); + } + Ok(lines.join("\n")) +} + +fn site_validation_body( + conn: &bds_core::db::DbConnection, + data_dir: &Path, + project_id: &str, +) -> Result { + let report = engine::validate_site::validate_site(conn, data_dir, project_id)?; + Ok(format!( + "Missing\n{}\n\nExtra\n{}\n\nStale\n{}", + report.missing_pages.join("\n"), + report.extra_pages.join("\n"), + report.stale_pages.join("\n") + )) +} + +impl TuiApp { + pub fn render(&mut self, frame: &mut Frame<'_>) { + let area = frame.area(); + self.resize(area.width, area.height); + self.render_buffer(area, frame.buffer_mut()); + } + + pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) { + let background = Style::default() + .bg(Color::Rgb(24, 26, 32)) + .fg(Color::Rgb(210, 214, 224)); + buffer.set_style(area, background); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(4), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(SIDEBAR_WIDTH.min(area.width.saturating_sub(8))), + Constraint::Min(8), + ]) + .split(vertical[0]); + self.render_sidebar(body[0], buffer); + self.render_main(body[1], buffer); + let output = self + .output + .last() + .map(|value| value.lines().last().unwrap_or("")) + .unwrap_or(""); + Paragraph::new(output) + .style( + Style::default() + .bg(Color::Rgb(35, 38, 46)) + .fg(Color::Rgb(153, 162, 184)), + ) + .render(vertical[1], buffer); + self.render_status(vertical[2], buffer); + if let Some(prompt) = &self.prompt { + self.render_prompt_overlay(area, prompt, buffer); + } + if let Some(overlay) = &self.overlay { + self.render_overlay(area, overlay, buffer); + } + } + + fn render_sidebar(&self, area: Rect, buffer: &mut Buffer) { + let filter = self + .filters + .get(&self.view) + .filter(|value| !value.is_empty()) + .map(|value| format!(" /{value}")) + .unwrap_or_default(); + let title = format!(" {}{} ", self.view_title(), filter); + let inner = Block::default() + .title(title) + .borders(Borders::RIGHT) + .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) + .inner(area); + Block::default() + .title(format!(" {}{} ", self.view_title(), filter)) + .borders(Borders::RIGHT) + .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) + .render(area, buffer); + let rows = self + .items + .iter() + .enumerate() + .map(|(index, item)| { + let style = if index == self.selected_index && self.focus == Focus::Sidebar { + Style::default() + .bg(Color::Rgb(54, 61, 78)) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else if matches!(item, SidebarItem::Header(_)) { + Style::default() + .fg(Color::Rgb(126, 137, 160)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Rgb(196, 201, 214)) + }; + let prefix = if matches!(item, SidebarItem::Header(_)) { + "" + } else { + " " + }; + Line::styled(format!("{prefix}{}", item.label(self.locale)), style) + }) + .collect::>(); + Paragraph::new(rows).render(inner, buffer); + } + + fn render_main(&self, area: Rect, buffer: &mut Buffer) { + if let Some(editor) = &self.editor { + self.render_editor(area, editor, buffer); + return; + } + if let Panel::MediaPreview { title, path } = &self.panel + && let Some(protocol) = &self.image_protocol + { + let block = Block::default() + .title(format!(" {title} — {} ", path.display())) + .padding(ratatui::widgets::Padding::new(1, 1, 1, 1)); + let inner = block.inner(area); + block.render(area, buffer); + TerminalImage::new(protocol).render(inner, buffer); + return; + } + let (title, text) = match &self.panel { + Panel::Welcome => ( + self.project + .as_ref() + .map(|project| project.name.clone()) + .unwrap_or_else(|| { + self.tr(if self.remote { + "remoteTerminal.serverTitle" + } else { + "remoteTerminal.localTitle" + }) + }), + self.tr("tui.help"), + ), + Panel::MediaPreview { title, path } => ( + title.clone(), + format!( + "Image preview\n\n{}\n\n{}", + path.display(), + image_dimensions(path).unwrap_or_else(|| "Preview unavailable".into()) + ), + ), + Panel::Settings(section) => (self.tr(section.key()), self.settings_text()), + Panel::Tags(section) => (self.tr(section.key()), self.tags_text(*section)), + Panel::Git => ( + "Git Diff".into(), + self.output + .last() + .cloned() + .unwrap_or_else(|| self.git_diff_text()), + ), + Panel::Report { title, body, .. } => ( + title.clone(), + format!("{body}\n\n{}", self.tr("tui.applyCancel")), + ), + Panel::Help => ("Commands".into(), command_names().join("\n")), + }; + Paragraph::new(text) + .block( + Block::default() + .title(format!(" {title} ")) + .borders(Borders::NONE) + .padding(ratatui::widgets::Padding::new(2, 2, 1, 1)), + ) + .wrap(Wrap { trim: false }) + .scroll((self.scroll, 0)) + .render(area, buffer); + } + + fn render_editor(&self, area: Rect, editor: &Editor, buffer: &mut Buffer) { + let dirty = if editor.buffer.is_dirty() { " ●" } else { "" }; + let language = editor.post_language.as_deref().unwrap_or(editor.syntax); + let title = format!( + " {} — {}{} ", + editor.title, + if editor.mode == EditorMode::Preview { + "Preview" + } else { + language + }, + dirty + ); + let inner = Block::default() + .title(title.clone()) + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) + .inner(area); + Block::default() + .title(title) + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(65, 70, 83))) + .render(area, buffer); + if editor.mode == EditorMode::Preview { + let source = editor.buffer.text(); + Paragraph::new(tui_markdown::from_str(&source)) + .wrap(Wrap { trim: false }) + .scroll((editor.buffer.scroll_offset() as u16, 0)) + .render(inner, buffer); + return; + } + let source = editor.buffer.text(); + let highlighter = bds_editor::highlighter(); + let extension = match editor.syntax { + "markdown" => "md", + other => other, + }; + let syntax = highlighter.syntax_for_extension(extension); + let cursor_line = editor.buffer.cursor().0; + let lines = highlighter + .highlight_lines(&source, syntax) + .into_iter() + .enumerate() + .map(|(index, spans)| { + let row_style = if index == cursor_line { + Style::default().bg(Color::Rgb(39, 43, 53)) + } else { + Style::default() + }; + Line::from( + spans + .into_iter() + .map(|(style, value)| { + Span::styled( + value.trim_end_matches('\n').to_owned(), + Style::default() + .fg(Color::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + )) + .bg(row_style.bg.unwrap_or(Color::Reset)), + ) + }) + .collect::>(), + ) + .style(row_style) + }) + .collect::>(); + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll((editor.buffer.scroll_offset() as u16, 0)) + .render(inner, buffer); + } + + fn render_status(&self, area: Rect, buffer: &mut Buffer) { + let transport = bds_core::i18n::translate_with( + self.locale, + "tui.status", + &[ + ( + "transport", + &self.tr(if self.remote { + "tui.remote" + } else { + "tui.local" + }), + ), + ("locale", self.locale.code()), + ], + ); + let text = if let Some(prompt) = &self.prompt { + let marker = match prompt.kind { + PromptKind::Search => "/", + PromptKind::Command => ":", + PromptKind::ProjectPath => "open ", + PromptKind::Commit => "commit ", + PromptKind::ConfirmDeleteTag(_) => "confirm ", + _ => "> ", + }; + format!("{marker}{}", prompt.value) + } else if !self.status.is_empty() { + format!("{transport} · {}", self.status) + } else { + transport + }; + Paragraph::new(text) + .style( + Style::default() + .bg(Color::Rgb(47, 52, 64)) + .fg(Color::Rgb(230, 233, 240)), + ) + .render(area, buffer); + } + + fn render_overlay(&self, area: Rect, overlay: &Overlay, buffer: &mut Buffer) { + let popup = centered( + area, + 64.min(area.width.saturating_sub(2)), + 16.min(area.height.saturating_sub(2)), + ); + Clear.render(popup, buffer); + let (title, lines) = match overlay { + Overlay::ConfirmDiscard => ( + self.tr("tui.unsavedTitle"), + vec![Line::from(self.tr("tui.unsavedPrompt"))], + ), + Overlay::Projects { selected } => { + let projects = self + .database() + .and_then(|db| engine::project::list_projects(db.conn()).map_err(Into::into)) + .unwrap_or_default(); + ( + self.tr("remoteTerminal.availableProjects"), + projects + .into_iter() + .enumerate() + .map(|(index, project)| { + Line::styled( + format!( + "{} {}", + if project.is_active { "●" } else { " " }, + project.name + ), + if index == *selected { + Style::default() + .bg(Color::Rgb(54, 61, 78)) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ) + }) + .collect(), + ) + } + }; + Paragraph::new(lines) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Rgb(31, 34, 42))), + ) + .render(popup, buffer); + } + + fn render_prompt_overlay(&self, area: Rect, prompt: &Prompt, buffer: &mut Buffer) { + if !matches!(prompt.kind, PromptKind::Command | PromptKind::ProjectPath) { + return; + } + let candidates = if prompt.kind == PromptKind::Command && prompt.value == "?" { + command_names() + .iter() + .map(|value| (*value).to_owned()) + .collect::>() + } else { + prompt.candidates.clone() + }; + if candidates.is_empty() && prompt.kind == PromptKind::ProjectPath { + return; + } + let height = (candidates.len() as u16 + 2).clamp(3, area.height.saturating_sub(3).max(3)); + let popup = Rect::new( + area.x + 2, + area.y + area.height.saturating_sub(height + 2), + area.width.saturating_sub(4), + height, + ); + Clear.render(popup, buffer); + let rows = candidates + .into_iter() + .enumerate() + .map(|(index, value)| { + Line::styled( + value, + if index == 0 { + Style::default() + .bg(Color::Rgb(54, 61, 78)) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ) + }) + .collect::>(); + Paragraph::new(rows) + .block( + Block::default() + .title(if prompt.kind == PromptKind::Command { + "Commands" + } else { + "Folders" + }) + .borders(Borders::ALL) + .style(Style::default().bg(Color::Rgb(31, 34, 42))), + ) + .render(popup, buffer); + } + + fn view_title(&self) -> String { + self.tr(match self.view { + TuiView::Posts => "tui.viewPosts", + TuiView::Media => "tui.viewMedia", + TuiView::Templates => "tui.viewTemplates", + TuiView::Scripts => "tui.viewScripts", + TuiView::Tags => "tui.viewTags", + TuiView::Settings => "tui.viewSettings", + TuiView::Git => "tui.viewGit", + }) + } + + fn settings_text(&self) -> String { + self.settings_fields + .iter() + .enumerate() + .map(|(index, field)| { + format!( + "{} {:<22} {}{}", + if index == self.panel_index { + "›" + } else { + " " + }, + field.label, + field.value, + if field.kind == FieldKind::ReadOnly { + " [read-only]" + } else { + "" + } + ) + }) + .collect::>() + .join("\n") + + "\n\nEnter edits/toggles · Ctrl+S saves · Esc closes" + } + + fn tags_text(&self, section: TagSection) -> String { + let Ok(mut tags) = self.tags() else { + return "Could not load tags".into(); + }; + let usage = self.tag_usage(); + if section == TagSection::Cloud { + tags.sort_by_key(|tag| { + std::cmp::Reverse(*usage.get(&tag.name.to_lowercase()).unwrap_or(&0)) + }); + } + let mut rows = tags + .iter() + .enumerate() + .map(|(index, tag)| { + format!( + "{}{} {:<24} {:>4} {} {}", + if index == self.panel_index { + "›" + } else { + " " + }, + if self.marked_tags.contains(&tag.id) { + "●" + } else { + " " + }, + tag.name, + usage.get(&tag.name.to_lowercase()).unwrap_or(&0), + tag.color.as_deref().unwrap_or("—"), + tag.post_template_slug.as_deref().unwrap_or("—") + ) + }) + .collect::>(); + if rows.is_empty() { + rows.push(self.tr("tui.noTags")); + } + rows.push( + match section { + TagSection::Cloud => "\nUsage-ordered tag cloud", + TagSection::Manage => { + "\nn create · Enter rename · c colour · t template · d delete · s sync" + } + TagSection::Merge => "\nSpace marks · select target · m merges", + } + .into(), + ); + rows.join("\n") + } + + fn tag_usage(&self) -> HashMap { + let Ok(db) = self.database() else { + return HashMap::new(); + }; + let Ok(project_id) = self.project_id() else { + return HashMap::new(); + }; + let Ok(posts) = bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id) + else { + return HashMap::new(); + }; + let mut usage = HashMap::new(); + for tag in posts.into_iter().flat_map(|post| post.tags) { + *usage.entry(tag.to_lowercase()).or_default() += 1; + } + usage + } + + fn git_diff_text(&self) -> String { + let Ok(data_dir) = self.data_dir() else { + return "No active project".into(); + }; + match engine::git::GitEngine::new(data_dir).diff() { + Ok(diff) => truncate_bytes( + &format!("Staged\n{}\n\nUnstaged\n{}", diff.staged, diff.unstaged), + MAX_DIFF_BYTES, + ), + Err(error) => error.to_string(), + } + } + + fn tr(&self, key: &str) -> String { + bds_core::i18n::translate(self.locale, key) + } +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ) +} + +fn image_dimensions(path: &Path) -> Option { + let data = fs::read(path).ok()?; + if data.starts_with(b"\x89PNG") && data.len() >= 24 { + return Some(format!( + "PNG · {} × {} px", + u32::from_be_bytes(data[16..20].try_into().ok()?), + u32::from_be_bytes(data[20..24].try_into().ok()?) + )); + } + if data.starts_with(b"GIF8") && data.len() >= 10 { + return Some(format!( + "GIF · {} × {} px", + u16::from_le_bytes(data[6..8].try_into().ok()?), + u16::from_le_bytes(data[8..10].try_into().ok()?) + )); + } + Some(format!("{} bytes", data.len())) +} + +pub fn run_local(host: ApplicationHost) -> Result<()> { + ratatui::run(|terminal| -> io::Result<()> { + let mut app = TuiApp::new(host, false).map_err(io::Error::other)?; + loop { + app.poll().map_err(io::Error::other)?; + terminal.draw(|frame| app.render(frame))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(input) = crossterm_input(key) { + app.handle_input(input).map_err(io::Error::other)?; + } + } + Event::Resize(_, _) => {} + _ => {} + } + } + if app.should_quit() { + break Ok(()); + } + } + }) + .map_err(Into::into) +} + +fn crossterm_input(key: KeyEvent) -> Option { + let modifiers = key.modifiers; + let key = match key.code { + KeyCode::Char(value) => TuiKey::Char(value), + KeyCode::Enter => TuiKey::Enter, + KeyCode::Esc => TuiKey::Esc, + KeyCode::Backspace => TuiKey::Backspace, + KeyCode::Delete => TuiKey::Delete, + KeyCode::Up => TuiKey::Up, + KeyCode::Down => TuiKey::Down, + KeyCode::Left => TuiKey::Left, + KeyCode::Right => TuiKey::Right, + KeyCode::Home => TuiKey::Home, + KeyCode::End => TuiKey::End, + KeyCode::PageUp => TuiKey::PageUp, + KeyCode::PageDown => TuiKey::PageDown, + KeyCode::Tab | KeyCode::BackTab => TuiKey::Tab, + _ => return None, + }; + Some(TuiInput { + key, + modifiers: TuiModifiers { + ctrl: modifiers.contains(KeyModifiers::CONTROL), + shift: modifiers.contains(KeyModifiers::SHIFT), + alt: modifiers.contains(KeyModifiers::ALT), + }, + }) +} + +#[derive(Default)] +pub(crate) struct InputDecoder { + pending: Vec, +} + +impl InputDecoder { + pub(crate) fn push(&mut self, bytes: &[u8]) -> Vec { + self.pending.extend_from_slice(bytes); + let mut inputs = Vec::new(); + loop { + let Some(first) = self.pending.first().copied() else { + break; + }; + if first == 0x1b { + if self.pending.len() == 1 { + break; + } + let sequences: &[(&[u8], TuiKey)] = &[ + (b"\x1b[A", TuiKey::Up), + (b"\x1b[B", TuiKey::Down), + (b"\x1b[C", TuiKey::Right), + (b"\x1b[D", TuiKey::Left), + (b"\x1b[H", TuiKey::Home), + (b"\x1b[F", TuiKey::End), + (b"\x1b[5~", TuiKey::PageUp), + (b"\x1b[6~", TuiKey::PageDown), + (b"\x1b[3~", TuiKey::Delete), + (b"\x1b[Z", TuiKey::Tab), + ]; + if let Some((sequence, key)) = sequences + .iter() + .find(|(sequence, _)| self.pending.starts_with(sequence)) + { + self.pending.drain(..sequence.len()); + inputs.push(TuiInput::plain(*key)); + continue; + } + if sequences + .iter() + .any(|(sequence, _)| sequence.starts_with(&self.pending)) + { + break; + } + self.pending.remove(0); + inputs.push(TuiInput::plain(TuiKey::Esc)); + continue; + } + let (input, consumed) = match first { + b'\r' | b'\n' => (Some(TuiInput::plain(TuiKey::Enter)), 1), + b'\t' => (Some(TuiInput::plain(TuiKey::Tab)), 1), + 0x7f | 0x08 => (Some(TuiInput::plain(TuiKey::Backspace)), 1), + 1..=26 => ( + Some(TuiInput::ctrl(TuiKey::Char((b'a' + first - 1) as char))), + 1, + ), + _ => match std::str::from_utf8(&self.pending) { + Ok(value) => { + let value = value.chars().next().expect("non-empty input"); + (Some(TuiInput::plain(TuiKey::Char(value))), value.len_utf8()) + } + Err(error) if error.error_len().is_none() => break, + Err(_) => (None, 1), + }, + }; + self.pending.drain(..consumed); + if let Some(input) = input { + inputs.push(input); + } + } + inputs + } + + pub(crate) fn flush(&mut self) -> Vec { + if self.pending.as_slice() == [0x1b] { + self.pending.clear(); + vec![TuiInput::plain(TuiKey::Esc)] + } else { + Vec::new() + } + } +} + +pub(crate) fn render_ansi(app: &mut TuiApp, width: u16, height: u16) -> Vec { + let width = width.max(20); + let height = height.max(6); + let area = Rect::new(0, 0, width, height); + app.resize(width, height); + let mut buffer = Buffer::empty(area); + app.render_buffer(area, &mut buffer); + let mut output = String::from("\x1b[?25l\x1b[2J\x1b[H"); + let mut last_style: Option