From 5ace1621250f17e429ddde26494fa9d59800b1a9 Mon Sep 17 00:00:00 2001 From: John Goh Date: Mon, 31 Aug 2026 11:19:34 +0800 Subject: [PATCH 001/110] Initial commit: zeddy agent multiplexer A GPUI window over herdr sessions, with a two-tier plugin system. 4,333 lines across five crates; cargo test --workspace is 68 green, clippy and fmt clean, live smoke test passes against a real herdr daemon. crates/zeddy-herdr/ the only code that knows herdr exists crates/zeddy/ the window crates/zeddy-plugin/ the contract a plugin is written against crates/zeddy-plugin-host/ the only code that loads foreign code crates/zeddy-vt/ the only code that knows a VT parser exists Verified working: private daemon in its own namespace (~/.local/state/zeddy/herdr); workspace adoption per directory; frames stream and paint; PTY resizes to the measured grid; sidebar and tab chromes render; both plugin tiers load (Hello is a real cdylib whose activate() ran across the ABI boundary). Decisions recorded in docs/adr/: 0001 a private herdr daemon 0002 gpui + ui + theme rather than Zed's workspace crate (makes zeddy GPL-3.0-or-later; native plugins inherit it) 0003 two plugin tiers (native cdylib, web) 0004 alacritty's VT core rather than libghostty Known gaps: - web plugin panes render a placeholder; the OS webview is not hosted yet - typing not tested end-to-end (no assistive access for synthetic keys); keys.rs is unit-tested per keystroke and the transport is live-tested Claude-Session: https://claude.ai/code/session_01Va1ZF7ko3JMvYWF2nxvewx --- .gitignore | 3 + CHARTR.md | 20 + Cargo.lock | 7386 +++++++++++++++++++++++++++ Cargo.toml | 82 + README.md | 135 + crates/zeddy-herdr/Cargo.toml | 15 + crates/zeddy-herdr/src/control.rs | 315 ++ crates/zeddy-herdr/src/lib.rs | 133 + crates/zeddy-herdr/src/namespace.rs | 148 + crates/zeddy-herdr/src/protocol.rs | 206 + crates/zeddy-herdr/src/sidecar.rs | 72 + crates/zeddy-herdr/src/stream.rs | 294 ++ crates/zeddy-plugin-host/Cargo.toml | 17 + crates/zeddy-plugin-host/src/lib.rs | 365 ++ crates/zeddy-plugin/Cargo.toml | 14 + crates/zeddy-plugin/src/lib.rs | 231 + crates/zeddy-plugin/src/manifest.rs | 244 + crates/zeddy-vt/Cargo.toml | 10 + crates/zeddy-vt/src/lib.rs | 354 ++ crates/zeddy/Cargo.toml | 27 + crates/zeddy/assets/icons/close.svg | 3 + crates/zeddy/assets/icons/menu.svg | 4 + crates/zeddy/assets/icons/plus.svg | 3 + crates/zeddy/assets/icons/tab.svg | 5 + crates/zeddy/build.rs | 45 + crates/zeddy/src/app.rs | 488 ++ crates/zeddy/src/assets.rs | 63 + crates/zeddy/src/chrome.rs | 54 + crates/zeddy/src/chrome/sidebar.rs | 95 + crates/zeddy/src/chrome/tabs.rs | 83 + crates/zeddy/src/fonts.rs | 96 + crates/zeddy/src/keys.rs | 147 + crates/zeddy/src/main.rs | 66 + crates/zeddy/src/mode.rs | 42 + crates/zeddy/src/palette.rs | 199 + crates/zeddy/src/session.rs | 182 + crates/zeddy/src/terminal.rs | 276 + crates/zeddy/tests/live_session.rs | 74 + docs/adr/0001-a-private-herdr.md | 41 + docs/adr/0002-the-zed-layer.md | 44 + docs/adr/0003-two-plugin-tiers.md | 47 + docs/adr/0004-the-vt-core.md | 40 + docs/adr/README.md | 11 + plugins/clock/index.html | 37 + plugins/clock/zeddy-plugin.toml | 6 + plugins/hello/Cargo.lock | 3912 ++++++++++++++ plugins/hello/Cargo.toml | 22 + plugins/hello/src/lib.rs | 61 + plugins/hello/zeddy-plugin.toml | 11 + rust-toolchain.toml | 5 + rustfmt.toml | 4 + vendor/herdr/LICENSE | 201 + vendor/herdr/fetch.sh | 69 + 53 files changed, 16507 insertions(+) create mode 100644 .gitignore create mode 100644 CHARTR.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/zeddy-herdr/Cargo.toml create mode 100644 crates/zeddy-herdr/src/control.rs create mode 100644 crates/zeddy-herdr/src/lib.rs create mode 100644 crates/zeddy-herdr/src/namespace.rs create mode 100644 crates/zeddy-herdr/src/protocol.rs create mode 100644 crates/zeddy-herdr/src/sidecar.rs create mode 100644 crates/zeddy-herdr/src/stream.rs create mode 100644 crates/zeddy-plugin-host/Cargo.toml create mode 100644 crates/zeddy-plugin-host/src/lib.rs create mode 100644 crates/zeddy-plugin/Cargo.toml create mode 100644 crates/zeddy-plugin/src/lib.rs create mode 100644 crates/zeddy-plugin/src/manifest.rs create mode 100644 crates/zeddy-vt/Cargo.toml create mode 100644 crates/zeddy-vt/src/lib.rs create mode 100644 crates/zeddy/Cargo.toml create mode 100644 crates/zeddy/assets/icons/close.svg create mode 100644 crates/zeddy/assets/icons/menu.svg create mode 100644 crates/zeddy/assets/icons/plus.svg create mode 100644 crates/zeddy/assets/icons/tab.svg create mode 100644 crates/zeddy/build.rs create mode 100644 crates/zeddy/src/app.rs create mode 100644 crates/zeddy/src/assets.rs create mode 100644 crates/zeddy/src/chrome.rs create mode 100644 crates/zeddy/src/chrome/sidebar.rs create mode 100644 crates/zeddy/src/chrome/tabs.rs create mode 100644 crates/zeddy/src/fonts.rs create mode 100644 crates/zeddy/src/keys.rs create mode 100644 crates/zeddy/src/main.rs create mode 100644 crates/zeddy/src/mode.rs create mode 100644 crates/zeddy/src/palette.rs create mode 100644 crates/zeddy/src/session.rs create mode 100644 crates/zeddy/src/terminal.rs create mode 100644 crates/zeddy/tests/live_session.rs create mode 100644 docs/adr/0001-a-private-herdr.md create mode 100644 docs/adr/0002-the-zed-layer.md create mode 100644 docs/adr/0003-two-plugin-tiers.md create mode 100644 docs/adr/0004-the-vt-core.md create mode 100644 docs/adr/README.md create mode 100644 plugins/clock/index.html create mode 100644 plugins/clock/zeddy-plugin.toml create mode 100644 plugins/hello/Cargo.lock create mode 100644 plugins/hello/Cargo.toml create mode 100644 plugins/hello/src/lib.rs create mode 100644 plugins/hello/zeddy-plugin.toml create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 vendor/herdr/LICENSE create mode 100755 vendor/herdr/fetch.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ef838c90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/vendor/herdr/*/ +plugins/*/target diff --git a/CHARTR.md b/CHARTR.md new file mode 100644 index 00000000..3f2f3655 --- /dev/null +++ b/CHARTR.md @@ -0,0 +1,20 @@ +chartr is the cockpit that drives this repository: it derives maps of tickets from +the files under `.plan/maps/` in this working tree and spawns one agent session +per ticket. + +A file under `.plan/maps/` is read by chartr only where it follows the format stated at `.chartr/TRACKER-CONVENTION.md`. + +--- + +# Context + +## Skill sources + +The skills chartr can resolve, in the order it resolves them. + +- `chartr-skills` at `.chartr/skills/chartr-skills` — grill, implement, prototype, research, to-spec, to-tickets, wayfinder +- `matt-pocock` at `.chartr/skills/matt-pocock` — ask-matt, code-review, codebase-design, diagnosing-bugs, domain-modeling, grill-with-docs, implement, improve-codebase-architecture, prototype, research, resolving-merge-conflicts, setup-matt-pocock-skills, tdd, to-spec, to-tickets, triage, wayfinder, wizard, claude-handoff, loop-me, setup-ts-deep-modules, writing-beats, writing-fragments, writing-shape, git-guardrails-claude-code, migrate-to-shoehorn, scaffold-exercises, setup-pre-commit, grill-me, grilling, handoff, teach, to-questionnaire, wait-what, writing-for-agents +- `impeccable` at `.chartr/skills/impeccable` — impeccable +- `emil-kowalski` at `.chartr/skills/emil-kowalski` — animate, animation-vocabulary, apple-design, ask-sonner, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations + +Where two of them carry a skill of the same name, the earlier one is what a bare name reaches, and the later one is reached as `source/skill`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..f8dde7d5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7386 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf 0.13.1", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +dependencies = [ + "accesskit", + "accesskit_consumer 0.37.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alacritty_terminal" +version = "0.26.1-dev" +source = "git+https://github.com/zed-industries/alacritty?rev=4c129667ce56611becdc82de6e28218c80e2e88f#4c129667ce56611becdc82de6e28218c80e2e88f" +dependencies = [ + "base64", + "bitflags 2.13.1", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.1", + "block", + "cocoa-foundation 0.2.1", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "component" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "collections", + "gpui", + "inventory", + "parking_lot", + "strum", + "theme", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "bzip2", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" +dependencies = [ + "bitflags 2.13.1", + "block", + "cfg-if", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" +dependencies = [ + "block", + "core-foundation 0.10.1", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.3", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "documented" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6b3e31251e87acd1b74911aed84071c8364fc9087972748ade2f1094ccce34" +dependencies = [ + "documented-macros", + "phf 0.12.1", + "thiserror 2.0.20", +] + +[[package]] +name = "documented-macros" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1149cf7462e5e79e17a3c05fd5b1f9055092bbfa95e04c319395c3beacc9370f" +dependencies = [ + "convert_case 0.8.0", + "itertools 0.14.0", + "optfield", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.20", + "windows 0.62.2", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "backtrace", + "bindgen", + "bitflags 2.13.1", + "chrono", + "collections", + "core-video", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", + "gpui_util", + "heapless", + "http_client", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "num_cpus", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "proptest", + "rand 0.9.5", + "raw-window-handle", + "refineable", + "regex", + "resvg", + "scheduler", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "stacksafe", + "strum", + "sum_tree", + "taffy", + "thiserror 2.0.20", + "tracing", + "ttf-parser", + "url", + "usvg", + "util_macros", + "uuid", + "waker-fn", + "web-time", + "windows 0.61.3", + "zed-scap", + "ztracing", +] + +[[package]] +name = "gpui_apple" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "block", + "cbindgen", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.1", + "core-video", + "derive_more", + "etagere", + "foreign-types", + "gpui", + "image", + "log", + "metal", + "objc", + "parking_lot", +] + +[[package]] +name = "gpui_linux" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "bytemuck", + "calloop", + "collections", + "futures", + "gpui", + "gpui_util", + "http_client", + "libc", + "log", + "notify-rust", + "oo7", + "parking_lot", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "url", + "uuid", +] + +[[package]] +name = "gpui_macos" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "async-task", + "block", + "block2 0.6.2", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.1", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "ctor", + "dispatch2", + "foreign-types", + "futures", + "gpui", + "gpui_apple", + "gpui_util", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "media", + "metal", + "objc", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-user-notifications", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_platform" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "console_error_panic_hook", + "gpui", + "gpui_linux", + "gpui_macos", + "gpui_web", + "gpui_windows", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui_web" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "scheduler", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui_wgpu" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "bytemuck", + "collections", + "cosmic-text", + "etagere", + "gpui", + "gpui_util", + "itertools 0.14.0", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "web-sys", + "wgpu", +] + +[[package]] +name = "gpui_windows" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "collections", + "dunce", + "etagere", + "futures", + "gpui", + "gpui_util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.5", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics 0.2.0", + "windows-registry", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icons" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "serde", + "strum", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.1", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.1", + "core-video", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "menu" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", +] + +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-location", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes", + "ashpd", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "digest", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "optfield" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros 0.12.1", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator 0.12.1", + "phf_shared 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.1", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "heapless", + "log", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "sval" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" + +[[package]] +name = "sval_buffer" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syntax_theme" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "taffy" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c034e05f6ee85a12daa63863c2245797715075c70649947aa0da54f3f2ab1d0f" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "theme" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "gpui", + "palette", + "parking_lot", + "refineable", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "strum", + "syntax_theme", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "ui" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "chrono", + "component", + "documented", + "gpui", + "gpui_macros", + "gpui_util", + "icons", + "itertools 0.14.0", + "log", + "menu", + "num-format", + "schemars", + "serde", + "smallvec", + "strum", + "theme", + "ui_macros", + "web-time", +] + +[[package]] +name = "ui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cursor-icon", + "log", + "memchr", + "serde", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set 0.9.1", + "bit-vec 0.9.1", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.9.1", + "bitflags 2.13.1", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.20", + "windows 0.61.3", + "windows-future 0.2.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "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", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "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", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zeddy" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "gpui", + "gpui_platform", + "tempfile", + "theme", + "ui", + "zeddy-herdr", + "zeddy-plugin", + "zeddy-plugin-host", + "zeddy-vt", +] + +[[package]] +name = "zeddy-herdr" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "zeddy-plugin" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "zeddy-plugin-host" +version = "0.1.0" +dependencies = [ + "gpui", + "libloading", + "tempfile", + "zeddy-plugin", +] + +[[package]] +name = "zeddy-vt" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..ce29fd60 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,82 @@ +# zeddy — a simple agent multiplexer. +# +# Five crates, each one a boundary rather than a bag of helpers: +# +# zeddy-herdr the only code that knows herdr exists +# zeddy-vt the only code that knows a VT parser exists +# zeddy-plugin the contract a native plugin is written against +# zeddy-plugin-host the only code that loads foreign code +# zeddy the window, and nothing a lower crate could own +# +# Nothing above depends on anything below it out of order, and no crate but +# `zeddy` links GPUI's platform backend. +[workspace] +resolver = "3" +members = [ + "crates/zeddy", + "crates/zeddy-herdr", + "crates/zeddy-plugin", + "crates/zeddy-plugin-host", + "crates/zeddy-vt", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +# Zed's `ui` and `theme` are GPL-3.0-or-later and zeddy links them directly, so +# zeddy is too. See docs/adr/0002. +license = "GPL-3.0-or-later" +repository = "https://github.com/rengwu/chartr-zeddy" + +[workspace.dependencies] +# --- the Zed layer ------------------------------------------------------- +# +# One pinned Zed revision supplies three crates. `gpui` is the framework, +# `gpui_platform` is the AppKit/Win32/Wayland backend, and `ui` + `theme` are +# Zed's own component kit and color system — the reason zeddy looks native +# rather than looks like a rewrite of native. Only `zeddy` may name +# `gpui_platform`: a plugin that linked a second window-server backend would +# register a second application with the OS. +gpui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", default-features = false } +# `font-kit` is NOT in gpui_platform's default features, and without it macOS +# falls back to a text system that rasterises nothing: a window that paints +# every quad and icon correctly and shows not one glyph, with the explanation +# behind a `log::warn!` that an app with no logger never sees. It is enabled +# here on purpose, and `fonts::available` checks the result at startup. +gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", features = ["font-kit"] } +ui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +theme = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +# --- the terminal -------------------------------------------------------- +# +# Zed's fork of alacritty's VT core. Taking the same parser Zed's own terminal +# uses is the whole point of picking it over libghostty-vt: no Zig in the +# build, and grid semantics that already agree with the renderer above them. +alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } + +# --- everything else ----------------------------------------------------- +anyhow = "1" +# Frames arrive on a thread of their own and must wake the window's thread +# without it polling. Already in the tree as one of GPUI's own dependencies, so +# it costs nothing new to build. +futures = "0.3" +# herdr's control plane is NDJSON and its frame payloads are base64. That is +# the entire wire format, so this is the entire wire dependency. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +toml = "0.9" +# The whole of the native plugin loader. There is no renderer, RPC transport, +# or display-list replay between a plugin's view and zeddy's element tree. +libloading = "0.8" +tempfile = "3" + +# The dev profile is the build whose window you actually drag. GPUI and the VT +# core are both unusably slow at opt-level 0, and neither is code we are +# debugging, so they are optimised even in dev while zeddy's own crates stay +# fast to rebuild. +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 0 diff --git a/README.md b/README.md new file mode 100644 index 00000000..02bef994 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# zeddy + +A simple agent multiplexer. Sessions live in a backend that outlives the +window; the window shows them in a sidebar or in a tab strip, and plugins add +panes beside them. + +```sh +sh vendor/herdr/fetch.sh # once per checkout, and whenever the pin moves +cargo run -p zeddy +``` + +## What it is + +Open zeddy in a directory and it shows the sessions already running there, +adopting them rather than restarting them. `+` starts another. Quitting leaves +them running; the next launch picks them up where they were. + +Sessions are **agents**, not just shells — the backend already knows what a +pane is running, so a session carries its agent's name and status without zeddy +inspecting a process tree. + +## Two modes + +The same list, in the two places a list of sessions wants to be: + +- **Sidebar** — a vertical list down the left. Room for a title, the agent + under it, and a close button that is not fighting the title for space. The + mode for many long-lived sessions. +- **Tabs** — a horizontal strip across the top. Denser per session, familiar, + and no room for a second line. The mode for a handful you are switching + between quickly. + +Both are one enum and one branch in `render`. Toggling never touches a session, +because nothing below the chrome knows which mode is showing. + +## Layout + +```text +crates/zeddy/ the window, and nothing a lower crate could own +crates/zeddy-herdr/ the only code that knows herdr exists +crates/zeddy-vt/ the only code that knows a VT parser exists +crates/zeddy-plugin/ the contract a plugin is written against +crates/zeddy-plugin-host/ the only code that loads foreign code +plugins/ one example per tier; never installed automatically +vendor/herdr/ the pinned backend executable and its fetch script +docs/adr/ the decisions that would otherwise be re-litigated +``` + +Each crate is a boundary rather than a bag of helpers. Swapping the VT parser +is a change to one file; so is swapping the backend. + +## The backend is invisible + +zeddy runs a **private** herdr: its own socket, its own XDG directories, its own +session name, under `~/.local/state/zeddy/herdr`. It does not discover, attach +to, stop, upgrade, or write the herdr you run yourself, and a `HERDR_SOCKET_PATH` +inherited from your shell cannot reach it — every herdr process zeddy launches is +placed in that namespace explicitly. + +There is no backend administration surface. Starting and adopting are one call, +because the question zeddy acts on is not "is it running" but "can I talk to +it", and that is a `ping`. + +The executable is resolved by path, beside zeddy's own, never through `PATH`: a +herdr you installed for yourself is yours, and picking it up would make zeddy's +backend version depend on the machine. `crates/zeddy/build.rs` copies the +vendored one into place and fails the build if it is not there. + +## Plugins + +Both tiers contribute the same thing — a pane zeddy can show in the sidebar or +as a tab — and nothing above the plugin host asks which tier a pane came from. +A plugin is a directory with a `zeddy-plugin.toml` in it under +`~/.local/share/zeddy/plugins//`. + +**Native** (`kind = "native"`) is a `cdylib`. Its view is an ordinary GPUI +`AnyView` mounted directly in zeddy's element tree, so scrolling, resizing, +focus, input, and painting use exactly the same frame path as a built-in. +There is no webview, Wasm runtime, synthetic window, display-list replay, or UI +RPC layer. The whole authoring contract is one trait, one macro, and a manifest: + +```rust +use zeddy_plugin::{Host, PaneKey, Plugin, Registrar, gpui, register}; + +struct StarMap; + +impl Plugin for StarMap { + const ID: &'static str = "com.example.starmap"; + fn new(_: Host, _: &mut gpui::App) -> Self { Self } + fn activate(&mut self, r: &mut Registrar, _: &mut gpui::App) { r.add_pane("map", "Star map"); } + fn view(&mut self, _: &PaneKey, _: &mut gpui::Window, cx: &mut gpui::App) -> gpui::AnyView { + cx.new(|_| MapView::default()).into() + } +} + +register!(StarMap); +``` + +That openness is also the trust model. A native plugin may use raw GPUI, any +compatible crate, the filesystem, processes, and the network; installing one is +installing native code, and no sandbox is claimed. + +**Web** (`kind = "web"`) is a manifest and an entry document. No Rust, no +toolchain, no ABI to match — anyone who has written a web page can write one, +and it is sandboxed. The trade is that its pane is composited rather than +painted on zeddy's frame path, so it is a frame behind the terminal beside it. + +The two tiers exist because "anyone can author one" and "fast enough to paint a +star map at 120fps" are different requirements, and one runtime cannot honestly +be both. See [ADR 0003](docs/adr/0003-two-plugin-tiers.md). + +`plugins/hello` is a complete native plugin; `plugins/clock` is a complete web +one. Neither is seeded or installed automatically. + +## Testing + +```sh +cargo test --workspace +``` + +Hermetic: no test contacts a herdr daemon. The pieces that can only be checked +against a real one are ignored by default and use the vendored executable: + +```sh +cargo test -p zeddy --test live_session -- --ignored --nocapture +``` + +Run that one when the herdr pin moves. The frame stream rides herdr's command +line, which carries no compatibility promise, and it is the one coupling no +unit test can see break. + +## Licence + +GPL-3.0-or-later. zeddy links Zed's `ui` and `theme` crates directly, and those +are GPL-3.0-or-later; see [ADR 0002](docs/adr/0002-the-zed-layer.md). diff --git a/crates/zeddy-herdr/Cargo.toml b/crates/zeddy-herdr/Cargo.toml new file mode 100644 index 00000000..e3ae5c34 --- /dev/null +++ b/crates/zeddy-herdr/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zeddy-herdr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +base64.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs new file mode 100644 index 00000000..e6be13df --- /dev/null +++ b/crates/zeddy-herdr/src/control.rs @@ -0,0 +1,315 @@ +//! The control plane: herdr's socket API. +//! +//! One request per connection, NDJSON, blocking. Blocking is deliberate — the +//! calls are local, they take microseconds, and the alternative is an async +//! runtime in a crate whose entire job is six methods. +//! +//! Callers on the window thread should still not sit on these directly; the app +//! runs them on a background executor and delivers the answer back. This crate +//! does not decide that for them. + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::UnixStream, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{ + Error, Geometry, Namespace, PaneId, Result, SUPPORTED_HERDR_VERSION, SUPPORTED_PROTOCOL, + Sidecar, WorkspaceId, + protocol::{ + self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, Pong, Request, Response, + TabCreateParams, WorkspaceCreateParams, WorkspaceList, + }, + stream::Attachment, +}; + +/// A pane as zeddy talks about it, once herdr's vocabulary has been left behind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Session { + pub id: PaneId, + pub workspace: WorkspaceId, + /// What to put on the tab. herdr's title if it has one, the agent's name if + /// it knows one, and the id only as a last resort — a tab always has a name. + pub title: String, + /// The agent herdr believes is running in the pane, if any. + pub agent: Option, + pub cwd: Option, +} + +impl From for Session { + fn from(pane: protocol::Pane) -> Self { + let title = pane + .title + .filter(|t| !t.trim().is_empty()) + .or_else(|| pane.display_agent.clone()) + .unwrap_or_else(|| pane.pane_id.clone()); + Self { + id: PaneId(pane.pane_id), + workspace: WorkspaceId(pane.workspace_id), + title, + agent: pane.display_agent, + cwd: pane.cwd.map(PathBuf::from), + } + } +} + +/// A connection-per-request client for zeddy's private daemon. +#[derive(Debug, Clone)] +pub struct Client { + sidecar: Sidecar, + namespace: Namespace, +} + +impl Client { + pub fn new(sidecar: Sidecar, namespace: Namespace) -> Self { + Self { sidecar, namespace } + } + + pub fn namespace(&self) -> &Namespace { + &self.namespace + } + + /// Bring the private daemon up if it is not already, then handshake it. + /// + /// Starting and adopting are the same call on purpose. zeddy has no + /// "is it running" question worth asking separately: the answer it acts on + /// is "can I talk to it", and that is a `ping`. + pub fn connect(&self, timeout: Duration) -> Result<()> { + self.namespace.prepare()?; + if self.handshake().is_ok() { + return Ok(()); + } + self.spawn_daemon()?; + + let deadline = Instant::now() + timeout; + let mut backoff = Duration::from_millis(10); + loop { + match self.handshake() { + Ok(()) => return Ok(()), + Err(err) if Instant::now() + backoff >= deadline => return Err(err), + Err(_) => { + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_millis(200)); + } + } + } + } + + /// `ping`, checked against the version this client was written for. + /// + /// A version mismatch is an error and not a warning. The frame stream rides + /// herdr's command line, so a daemon zeddy did not ship is a daemon zeddy + /// cannot promise to render. + pub fn handshake(&self) -> Result<()> { + let pong: Pong = self.call("ping", &Empty {})?; + if pong.version != SUPPORTED_HERDR_VERSION || pong.protocol != SUPPORTED_PROTOCOL { + return Err(Error::Backend { + method: "ping", + message: format!( + "daemon is herdr {} (protocol {}); zeddy ships {SUPPORTED_HERDR_VERSION} \ + (protocol {SUPPORTED_PROTOCOL})", + pong.version, pong.protocol + ), + }); + } + Ok(()) + } + + /// Every pane the private daemon is running, or every pane in one workspace. + pub fn sessions(&self, workspace: Option<&WorkspaceId>) -> Result> { + let params = PaneListParams { workspace_id: workspace.map(|w| w.0.as_str()) }; + let list: PaneList = self.call("pane.list", ¶ms)?; + Ok(list.panes.into_iter().map(Session::from).collect()) + } + + /// Every workspace, so the sidebar has something to list. + pub fn workspaces(&self) -> Result> { + let list: WorkspaceList = self.call("workspace.list", &Empty {})?; + Ok(list.workspaces) + } + + /// The workspace already open on a directory, if there is one. + /// + /// herdr's workspace listing does not carry a working directory, but its + /// panes do, so a workspace is identified by where its sessions are. + /// + /// Both sides are resolved before they are compared. On macOS `/tmp` and + /// `/var` are symlinks into `/private`, so the path a process reports and + /// the path its user typed are routinely different spellings of one + /// directory — and a comparison that misses opens a second workspace on the + /// same folder every launch. + pub fn workspace_at(&self, cwd: &Path) -> Result> { + let cwd = resolved(cwd); + Ok(self + .sessions(None)? + .into_iter() + .find(|session| session.cwd.as_deref().map(resolved) == Some(cwd.clone())) + .map(|session| session.workspace)) + } + + /// The workspace for a directory: the one already open on it, or a new one. + /// + /// Adopting rather than always creating is the point of a backend that + /// outlives the window. Without it, every launch would leave behind another + /// workspace holding the previous launch's sessions. + pub fn open_workspace(&self, cwd: &Path, label: Option<&str>) -> Result { + if let Some(existing) = self.workspace_at(cwd)? { + return Ok(existing); + } + let params = WorkspaceCreateParams { cwd: &cwd.to_string_lossy(), label }; + let created: Created = self.call("workspace.create", ¶ms)?; + Ok(Session::from(created.root_pane).workspace) + } + + /// Start one more session in a workspace that is already open. + pub fn start_session(&self, workspace: &WorkspaceId, cwd: Option<&str>) -> Result { + let params = TabCreateParams { workspace_id: &workspace.0, cwd }; + let created: Created = self.call("tab.create", ¶ms)?; + Ok(created.root_pane.into()) + } + + /// End a session. The pane and whatever is running in it both go. + pub fn close_session(&self, pane: &PaneId) -> Result<()> { + let _: serde_json::Value = + self.call("pane.close", &PaneCloseParams { pane_id: &pane.0 })?; + Ok(()) + } + + /// Attach to a session's byte stream at a given geometry. + /// + /// The client hands its own sidecar and namespace to the attachment, so the + /// stream cannot end up pointed at a herdr the control plane is not talking + /// to. + pub fn attach(&self, pane: &PaneId, geometry: Geometry) -> Result { + Attachment::open(&self.sidecar, &self.namespace, pane, geometry) + } + + /// Send one request, read one response, close the connection. + fn call( + &self, + method: &'static str, + params: &P, + ) -> Result { + let socket = self.namespace.socket(); + let mut stream = UnixStream::connect(&socket).map_err(|err| { + Error::Transport(std::io::Error::new( + err.kind(), + format!("{}: {err}", socket.display()), + )) + })?; + + let request = Request { id: next_id(), method, params }; + let mut line = serde_json::to_vec(&request) + .map_err(|err| Error::Protocol(format!("cannot encode {method}: {err}")))?; + line.push(b'\n'); + stream.write_all(&line)?; + stream.flush()?; + + let mut reply = String::new(); + BufReader::new(&stream).read_line(&mut reply)?; + if reply.trim().is_empty() { + return Err(Error::Protocol(format!("{method} got no answer"))); + } + + let response: Response = serde_json::from_str(&reply) + .map_err(|err| Error::Protocol(format!("cannot read the answer to {method}: {err}")))?; + match (response.result, response.error) { + (Some(result), _) => Ok(result), + (None, Some(error)) => Err(Error::Backend { + method, + message: if error.code.is_empty() { + error.message + } else { + format!("{} ({})", error.message, error.code) + }, + }), + (None, None) => { + Err(Error::Protocol(format!("{method} answered with neither result nor error"))) + } + } + } + + /// Launch the private daemon detached, with its log as its only output. + fn spawn_daemon(&self) -> Result<()> { + let log = std::fs::File::create(self.namespace.log())?; + let mut command = Command::new(self.sidecar.path()); + command + .arg("server") + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone()?)) + .stderr(Stdio::from(log)); + apply(&mut command, &self.namespace); + command.spawn()?; + Ok(()) + } +} + +/// Place a command in a namespace: set what it pins, remove what it must not +/// inherit. +pub(crate) fn apply(command: &mut Command, namespace: &Namespace) { + for (key, value) in namespace.env() { + match value { + Some(value) => command.env(key, value), + None => command.env_remove(key), + }; + } +} + +/// A path with symlinks resolved, or the path itself where it cannot be — a +/// directory that no longer exists is not a directory a workspace is open on, +/// and the comparison above should simply not match it. +fn resolved(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_owned()) +} + +/// Request ids only have to be unique within one connection, and there is one +/// request per connection, so a counter is enough and a UUID would be theatre. +fn next_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pane(id: &str, title: Option<&str>, agent: Option<&str>) -> protocol::Pane { + protocol::Pane { + pane_id: id.to_owned(), + workspace_id: "w1".to_owned(), + title: title.map(str::to_owned), + display_agent: agent.map(str::to_owned), + cwd: None, + } + } + + #[test] + fn a_tab_falls_back_from_title_to_agent_to_id() { + assert_eq!(Session::from(pane("p1", Some("build"), Some("claude"))).title, "build"); + assert_eq!(Session::from(pane("p1", None, Some("claude"))).title, "claude"); + assert_eq!(Session::from(pane("p1", None, None)).title, "p1"); + } + + #[test] + fn a_blank_title_is_not_a_title() { + assert_eq!(Session::from(pane("p1", Some(" "), Some("codex"))).title, "codex"); + } + + #[test] + fn a_missing_socket_names_the_socket_it_looked_for() { + let tmp = tempfile::tempdir().expect("tempdir"); + let namespace = Namespace::rooted(tmp.path()); + let herdr = tmp.path().join("herdr"); + std::fs::write(&herdr, b"#!/bin/sh\n").expect("write"); + let client = Client::new(Sidecar::at(&herdr).expect("sidecar"), namespace.clone()); + + let err = client.handshake().expect_err("nothing is listening"); + assert!(err.to_string().contains(&namespace.socket().display().to_string()), "{err}"); + } +} diff --git a/crates/zeddy-herdr/src/lib.rs b/crates/zeddy-herdr/src/lib.rs new file mode 100644 index 00000000..72045449 --- /dev/null +++ b/crates/zeddy-herdr/src/lib.rs @@ -0,0 +1,133 @@ +//! zeddy's client for herdr, the backend that owns every PTY. +//! +//! herdr is infrastructure zeddy hides rather than a feature zeddy exposes. +//! Nothing above this crate knows the name, and the only thing this crate +//! promises upward is: a list of live panes, a stream of bytes per pane, and a +//! way to push bytes and geometry back down. +//! +//! # Two surfaces, two transports +//! +//! - [`control`] — the socket API. NDJSON over a Unix socket, one request per +//! connection. Creating, listing, and closing panes happens here. +//! - [`stream`] — the terminal byte stream. herdr exposes this as a CLI stream, +//! not a socket method, so attaching means spawning a child process. That is +//! a real coupling to herdr's command line and it is why +//! [`SUPPORTED_HERDR_VERSION`] is pinned rather than probed. +//! +//! # Whose herdr +//! +//! zeddy runs a **private** daemon: its own socket, its own XDG directories, +//! its own session name. A [`Namespace`] is those locations plus the +//! environment every herdr process zeddy launches is placed in, and both a +//! [`control::Client`] and a [`stream::Attachment`] carry one. An inherited +//! `HERDR_SOCKET_PATH` from the user's own shell therefore cannot split zeddy +//! across two backends. +//! +//! The user's own herdr is never discovered, attached to, stopped, upgraded, or +//! written. + +#![forbid(unsafe_code)] +#![cfg(unix)] + +pub mod control; +pub mod namespace; +pub mod protocol; +pub mod sidecar; +pub mod stream; + +pub use namespace::Namespace; +pub use sidecar::Sidecar; + +use std::fmt; + +/// The exact herdr release this client speaks to. +/// +/// Not a floor and not a range. The frame stream rides herdr's command line, +/// which carries no compatibility promise, so the client and the vendored +/// executable move together or not at all. +pub const SUPPORTED_HERDR_VERSION: &str = "0.8.0"; + +/// The socket API protocol version [`SUPPORTED_HERDR_VERSION`] speaks. +pub const SUPPORTED_PROTOCOL: u32 = 19; + +/// One terminal — a herdr *pane*. +/// +/// herdr's own "session" is a whole named server instance holding many of +/// these. This crate speaks herdr's vocabulary at the wire and zeddy's above +/// it, and this newtype is where the two meet. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PaneId(pub String); + +/// A group of panes — a herdr *workspace*, one per project directory. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct WorkspaceId(pub String); + +impl fmt::Display for PaneId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// The grid a pane's PTY is running at. +/// +/// zeddy owns this, not herdr: the window decides how many cells fit and tells +/// the backend, never the other way round. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Geometry { + pub cols: u16, + pub rows: u16, +} + +impl Geometry { + /// A geometry clamped to what a PTY will accept. A zero-sized grid is a + /// real thing to compute during a resize and not a real thing to send. + pub fn new(cols: u16, rows: u16) -> Self { + Self { cols: cols.max(1), rows: rows.max(1) } + } +} + +impl Default for Geometry { + fn default() -> Self { + Self::new(80, 24) + } +} + +/// Everything that can go wrong between zeddy and its backend. +#[derive(Debug)] +pub enum Error { + /// The vendored executable is missing or is the wrong build. + Sidecar(String), + /// The socket refused, closed, or was never there. + Transport(std::io::Error), + /// herdr answered, and the answer was a failure. + Backend { method: &'static str, message: String }, + /// herdr answered with something this client cannot read. + Protocol(String), +} + +pub type Result = std::result::Result; + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sidecar(why) => write!(f, "herdr sidecar unusable: {why}"), + Self::Transport(err) => write!(f, "herdr transport failed: {err}"), + Self::Backend { method, message } => write!(f, "herdr rejected {method}: {message}"), + Self::Protocol(why) => write!(f, "herdr sent something unreadable: {why}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Self::Transport(err) + } +} diff --git a/crates/zeddy-herdr/src/namespace.rs b/crates/zeddy-herdr/src/namespace.rs new file mode 100644 index 00000000..2ac1fde9 --- /dev/null +++ b/crates/zeddy-herdr/src/namespace.rs @@ -0,0 +1,148 @@ +//! Where zeddy's private herdr lives, and the environment it lives in. +//! +//! Every path here is under a single zeddy-owned root, so "which herdr" is one +//! decision made once rather than a rule each call site has to remember. The +//! environment in [`Namespace::env`] is applied to every herdr process zeddy +//! launches — the daemon and each frame stream alike — which is what keeps a +//! `HERDR_SOCKET_PATH` inherited from the user's shell from reaching herdr at +//! all. + +use std::{ffi::OsString, path::PathBuf}; + +/// The private locations and environment of zeddy's own herdr. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Namespace { + root: PathBuf, + session: String, +} + +impl Namespace { + /// The namespace zeddy uses in production: `/zeddy/herdr`. + /// + /// State rather than config or cache, because what lives here is neither + /// something an operator edits nor something safe to evict mid-session. + pub fn private() -> Self { + Self::rooted(state_home().join("zeddy").join("herdr")) + } + + /// A namespace under an arbitrary root. Tests use this to get a whole + /// private backend in a scratch directory; nothing else should need it. + pub fn rooted(root: impl Into) -> Self { + Self { root: root.into(), session: "zeddy".to_owned() } + } + + /// The Unix socket the control plane connects to. + pub fn socket(&self) -> PathBuf { + self.root.join("herdr.sock") + } + + /// The daemon's log, which is the only thing here a human reads. + pub fn log(&self) -> PathBuf { + self.root.join("daemon.log") + } + + /// herdr's named session inside this namespace. + pub fn session(&self) -> &str { + &self.session + } + + /// Create every directory herdr will expect to write into. + pub fn prepare(&self) -> std::io::Result<()> { + for dir in [ + &self.root, + &self.xdg("config"), + &self.xdg("state"), + &self.xdg("data"), + &self.xdg("cache"), + ] { + std::fs::create_dir_all(dir)?; + } + Ok(()) + } + + /// The environment every herdr process zeddy launches runs in. + /// + /// The XDG redirections keep herdr's config, state, data, and cache inside + /// the private root. The `HERDR_*` entries pin which daemon it talks to. + /// `HERDR_WORKSPACE_ID`, `HERDR_TAB_ID`, and `HERDR_PANE_ID` are cleared + /// rather than set: they identify the pane a process was *launched from*, + /// and zeddy is not launched from one. + pub fn env(&self) -> Vec<(OsString, Option)> { + let set = |k: &str, v: OsString| (OsString::from(k), Some(v)); + let clear = |k: &str| (OsString::from(k), None); + vec![ + set("XDG_CONFIG_HOME", self.xdg("config").into()), + set("XDG_STATE_HOME", self.xdg("state").into()), + set("XDG_DATA_HOME", self.xdg("data").into()), + set("XDG_CACHE_HOME", self.xdg("cache").into()), + set("HERDR_SOCKET_PATH", self.socket().into()), + set("HERDR_SESSION", self.session.clone().into()), + clear("HERDR_CLIENT_SOCKET_PATH"), + clear("HERDR_CONFIG_PATH"), + clear("HERDR_ENV"), + clear("HERDR_WORKSPACE_ID"), + clear("HERDR_TAB_ID"), + clear("HERDR_PANE_ID"), + ] + } + + fn xdg(&self, which: &str) -> PathBuf { + self.root.join("xdg").join(which) + } +} + +/// `$XDG_STATE_HOME`, or the platform default when it is unset or relative. +fn state_home() -> PathBuf { + if let Some(dir) = std::env::var_os("XDG_STATE_HOME") { + let dir = PathBuf::from(dir); + if dir.is_absolute() { + return dir; + } + } + home().join(".local").join("state") +} + +fn home() -> PathBuf { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(std::env::temp_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_private_path_stays_under_the_root() { + let ns = Namespace::rooted("/scratch/root"); + for path in [ns.socket(), ns.log(), ns.xdg("config"), ns.xdg("state")] { + assert!(path.starts_with("/scratch/root"), "{path:?} escaped the private root"); + } + } + + #[test] + fn inherited_herdr_context_is_cleared_not_merely_overridden() { + let ns = Namespace::rooted("/scratch/root"); + let env = ns.env(); + for key in ["HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_CONFIG_PATH"] { + let entry = env.iter().find(|(k, _)| k == key).expect("key is in the namespace env"); + assert!(entry.1.is_none(), "{key} must be removed, not set"); + } + } + + #[test] + fn the_socket_and_the_env_agree() { + let ns = Namespace::rooted("/scratch/root"); + let env = ns.env(); + let socket = + env.iter().find(|(k, _)| k == "HERDR_SOCKET_PATH").and_then(|(_, v)| v.clone()); + assert_eq!(socket, Some(ns.socket().into())); + } + + #[test] + fn prepare_creates_what_herdr_will_write_into() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ns = Namespace::rooted(tmp.path().join("ns")); + ns.prepare().expect("prepare"); + assert!(ns.socket().parent().expect("root").is_dir()); + assert!(ns.xdg("config").is_dir()); + } +} diff --git a/crates/zeddy-herdr/src/protocol.rs b/crates/zeddy-herdr/src/protocol.rs new file mode 100644 index 00000000..4ad98fc2 --- /dev/null +++ b/crates/zeddy-herdr/src/protocol.rs @@ -0,0 +1,206 @@ +//! herdr's wire types — exactly the ones zeddy sends or reads, and no more. +//! +//! herdr's socket API has ninety methods. zeddy uses six of them. Modelling +//! only those keeps the pin in [`crate::SUPPORTED_HERDR_VERSION`] honest: a +//! herdr release can change anything zeddy does not name here without zeddy +//! having an opinion about it. +//! +//! Every response field zeddy does not require is `#[serde(default)]`, because +//! herdr adds fields between releases and a new one must not fail a parse. + +use serde::{Deserialize, Serialize}; + +/// The control-plane request envelope. One of these per connection. +#[derive(Debug, Serialize)] +pub struct Request

{ + pub id: String, + pub method: &'static str, + pub params: P, +} + +/// The control-plane response envelope: exactly one of `result` or `error`. +/// +/// The explicit bound keeps serde's derive from also demanding `R: Default`, +/// which it would infer from the `default` attributes below. +#[derive(Debug, Deserialize)] +#[serde(bound(deserialize = "R: Deserialize<'de>"))] +pub struct Response { + #[serde(default)] + pub result: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ErrorBody { + #[serde(default)] + pub code: String, + #[serde(default)] + pub message: String, +} + +/// Methods take a params object even when they take no parameters. +#[derive(Debug, Serialize)] +pub struct Empty {} + +/// `ping` — the handshake. Its answer is the version check. +#[derive(Debug, Deserialize)] +pub struct Pong { + #[serde(default)] + pub version: String, + #[serde(default)] + pub protocol: u32, +} + +/// A herdr workspace: one project directory's worth of panes. +#[derive(Debug, Clone, Deserialize)] +pub struct Workspace { + pub workspace_id: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub pane_count: u32, +} + +/// A herdr pane: one terminal, which is what zeddy shows in one tab. +#[derive(Debug, Clone, Deserialize)] +pub struct Pane { + pub pane_id: String, + #[serde(default)] + pub workspace_id: String, + /// herdr's own title for the pane, when it has worked one out. + #[serde(default)] + pub title: Option, + /// The command herdr believes is running — `claude`, `codex`, and so on. + /// This is the whole reason zeddy is an *agent* multiplexer and not a + /// terminal multiplexer: the backend already knows what a pane is running. + #[serde(default)] + pub display_agent: Option, + #[serde(default)] + pub cwd: Option, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceCreateParams<'a> { + pub cwd: &'a str, + pub label: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct TabCreateParams<'a> { + pub workspace_id: &'a str, + pub cwd: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct PaneListParams<'a> { + pub workspace_id: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct PaneCloseParams<'a> { + pub pane_id: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct WorkspaceList { + #[serde(default)] + pub workspaces: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PaneList { + #[serde(default)] + pub panes: Vec, +} + +/// `workspace.create` and `tab.create` both answer with the pane they opened. +#[derive(Debug, Deserialize)] +pub struct Created { + pub root_pane: Pane, +} + +// --- the data plane ------------------------------------------------------ + +/// A line of `herdr terminal session control`'s stdout. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum StreamMessage { + #[serde(rename = "terminal.frame")] + Frame(RawFrame), + #[serde(rename = "terminal.closed")] + Closed(Closed), +} + +/// A repaint, as it arrives: base64 ANSI plus the geometry it was painted for. +/// +/// Only the first frame after an attach or a resize is `full`. Every other one +/// is a diff against what the frames before it drew, which is why +/// [`crate::stream::Frames`] refuses a stream with a gap in `seq` rather than +/// painting a plausible-looking wrong screen. +#[derive(Debug, Clone, Deserialize)] +pub struct RawFrame { + pub bytes: String, + #[serde(default)] + pub full: bool, + #[serde(default)] + pub seq: u64, + #[serde(default)] + pub width: u16, + #[serde(default)] + pub height: u16, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Closed { + #[serde(default)] + pub reason: String, +} + +/// A line written to `herdr terminal session control`'s stdin. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum StreamCommand { + #[serde(rename = "terminal.input")] + Input { bytes: String }, + #[serde(rename = "terminal.resize")] + Resize { cols: u16, rows: u16 }, + #[serde(rename = "terminal.release")] + Release, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_response_carrying_an_error_parses_as_one() { + let raw = r#"{"id":"1","error":{"code":"not_found","message":"no such pane"}}"#; + let parsed: Response = serde_json::from_str(raw).expect("parses"); + assert!(parsed.result.is_none()); + assert_eq!(parsed.error.expect("error").message, "no such pane"); + } + + #[test] + fn unknown_response_fields_do_not_fail_the_parse() { + let raw = r#"{"id":"1","result":{"panes":[{"pane_id":"p1","invented_in_0_9":true}]}}"#; + let parsed: Response = serde_json::from_str(raw).expect("parses"); + assert_eq!(parsed.result.expect("result").panes[0].pane_id, "p1"); + } + + #[test] + fn stream_messages_are_tagged_by_type() { + let raw = r#"{"type":"terminal.frame","bytes":"aGk=","full":true,"seq":0,"width":80,"height":24}"#; + match serde_json::from_str::(raw).expect("parses") { + StreamMessage::Frame(frame) => assert!(frame.full && frame.seq == 0), + StreamMessage::Closed(_) => panic!("that was a frame"), + } + } + + #[test] + fn commands_serialise_the_way_herdr_reads_them() { + let json = serde_json::to_string(&StreamCommand::Resize { cols: 120, rows: 40 }) + .expect("serialises"); + assert_eq!(json, r#"{"type":"terminal.resize","cols":120,"rows":40}"#); + } +} diff --git a/crates/zeddy-herdr/src/sidecar.rs b/crates/zeddy-herdr/src/sidecar.rs new file mode 100644 index 00000000..add06dbe --- /dev/null +++ b/crates/zeddy-herdr/src/sidecar.rs @@ -0,0 +1,72 @@ +//! The herdr executable zeddy ships, resolved by path and never through `PATH`. +//! +//! Both halves of the client — the socket and the frame stream — spawn or +//! handshake herdr, and they must be the same build. Resolving once and +//! carrying the result makes that true by construction instead of by +//! convention. + +use std::path::{Path, PathBuf}; + +use crate::{Error, Result, SUPPORTED_HERDR_VERSION}; + +/// A resolved herdr executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sidecar { + path: PathBuf, +} + +impl Sidecar { + /// The herdr beside zeddy's own executable, as the build script placed it. + /// + /// `PATH` is deliberately not consulted. A herdr the user installed for + /// themselves is theirs; picking it up would make zeddy's backend version + /// depend on the machine. + pub fn beside_current_exe() -> Result { + let exe = std::env::current_exe().map_err(|err| { + Error::Sidecar(format!("cannot locate zeddy's own executable: {err}")) + })?; + let dir = exe + .parent() + .ok_or_else(|| Error::Sidecar(format!("{} has no directory", exe.display())))?; + Self::at(dir.join("herdr")) + } + + /// A sidecar at an exact path, checked for existence only. + /// + /// The version is *not* probed here. Probing costs a process launch on a + /// path the window is waiting on, and a wrong version surfaces at the + /// handshake anyway, with a better message. + pub fn at(path: impl Into) -> Result { + let path = path.into(); + if !path.is_file() { + return Err(Error::Sidecar(format!( + "no herdr {SUPPORTED_HERDR_VERSION} at {}; run `sh vendor/herdr/fetch.sh`", + path.display() + ))); + } + Ok(Self { path }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_sidecar_says_how_to_get_one() { + let err = Sidecar::at("/nowhere/herdr").expect_err("must not resolve"); + let message = err.to_string(); + assert!(message.contains("fetch.sh"), "{message}"); + assert!(message.contains(SUPPORTED_HERDR_VERSION), "{message}"); + } + + #[test] + fn a_directory_is_not_an_executable() { + let tmp = tempfile::tempdir().expect("tempdir"); + assert!(Sidecar::at(tmp.path()).is_err()); + } +} diff --git a/crates/zeddy-herdr/src/stream.rs b/crates/zeddy-herdr/src/stream.rs new file mode 100644 index 00000000..98786204 --- /dev/null +++ b/crates/zeddy-herdr/src/stream.rs @@ -0,0 +1,294 @@ +//! The data plane: one session's stream of screen repaints. +//! +//! herdr exposes this as a CLI stream rather than a socket method, so attaching +//! means spawning `herdr terminal session control ` and speaking NDJSON +//! over its stdio. +//! +//! # Frames are diffs +//! +//! Only the first frame after an attach or a resize repaints the whole screen. +//! Every frame after it is a delta that assumes its predecessors were applied, +//! so a consumer must feed **every** frame, in order, to **one** emulator, and +//! must never skip one to catch up. [`Frames`] enforces exactly that: it +//! refuses a stream that does not start with a full repaint, and refuses a gap, +//! a repeat, or a rewind in the sequence. A broken stream surfaces as an error +//! rather than as a screen that looks plausible and is wrong. +//! +//! # Reading and writing are two halves +//! +//! [`Frames::next_frame`] blocks until herdr has something to say, which for an idle +//! session is "never". Anything that also has to deliver a keystroke the +//! instant it is typed cannot hold both ends on one thread, so [`Attachment`] +//! splits: the reader goes to a thread of its own, the [`Input`] half stays +//! with whatever is driving the session. The child outlives whichever half is +//! dropped first, and dies when both are gone. + +use std::{ + io::{BufRead, BufReader, Write}, + process::{Child, ChildStdin, ChildStdout, Command, Stdio}, + sync::{Arc, Mutex}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + +use crate::{ + Error, Geometry, Namespace, PaneId, Result, Sidecar, control, + protocol::{RawFrame, StreamCommand, StreamMessage}, +}; + +/// One repaint, decoded and ready to feed to a VT parser. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + /// ANSI bytes. Feed them to the emulator verbatim. + pub bytes: Vec, + /// Whether this frame repaints the whole screen rather than a region of it. + pub full: bool, + /// herdr's monotonic counter. + pub seq: u64, + /// The geometry this frame was painted for. After a resize, the first frame + /// carrying the new geometry is also the one that repaints in full. + pub geometry: Geometry, +} + +/// The child process, shared by both halves so that dropping one does not end +/// the attachment the other is still using. +#[derive(Debug)] +struct Process(Mutex); + +impl Drop for Process { + fn drop(&mut self) { + if let Ok(mut child) = self.0.lock() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// A live attachment to one session. +#[derive(Debug)] +pub struct Attachment { + frames: Frames, + input: Input, +} + +impl Attachment { + /// Attach to `pane` at `geometry`. + /// + /// The geometry is passed at attach time rather than sent afterwards so + /// that the very first full repaint is already the right size — a terminal + /// that opens at 80×24 and corrects itself a frame later is a visible flash. + pub fn open( + sidecar: &Sidecar, + namespace: &Namespace, + pane: &PaneId, + geometry: Geometry, + ) -> Result { + let mut command = Command::new(sidecar.path()); + command + .args(["terminal", "session", "control", &pane.0]) + .args(["--cols".to_owned(), geometry.cols.to_string()]) + .args(["--rows".to_owned(), geometry.rows.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + control::apply(&mut command, namespace); + + let mut child = command.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Protocol("herdr's frame stream has no stdout".to_owned()))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| Error::Protocol("herdr's frame stream has no stdin".to_owned()))?; + + let process = Arc::new(Process(Mutex::new(child))); + Ok(Self { + frames: Frames { + reader: BufReader::new(stdout), + sequence: Sequence::default(), + _process: process.clone(), + }, + input: Input { stdin, _process: process }, + }) + } + + /// Hand the two halves out, so the reader can go to its own thread. + pub fn split(self) -> (Frames, Input) { + (self.frames, self.input) + } +} + +/// The frame contract, as a state machine of its own. +/// +/// Split out from [`Frames`] so the rule can be tested without a child process +/// — and so there is exactly one place that decides whether a frame is safe to +/// paint. +#[derive(Debug, Default)] +struct Sequence { + /// The `seq` the next frame must carry. `None` before the first one, which + /// is also the one that must be a full repaint. + expected: Option, +} + +impl Sequence { + /// Check a frame against the contract, and decode it if it holds. + fn accept(&mut self, raw: RawFrame) -> Result { + match self.expected { + None if !raw.full => { + return Err(Error::Protocol(format!( + "the stream opened with a diff (seq {}) instead of a full repaint", + raw.seq + ))); + } + Some(expected) if raw.seq != expected => { + return Err(Error::Protocol(format!( + "frame {} arrived where {expected} was due; the screen would be wrong", + raw.seq + ))); + } + _ => {} + } + self.expected = Some(raw.seq + 1); + + let bytes = BASE64 + .decode(raw.bytes.as_bytes()) + .map_err(|err| Error::Protocol(format!("frame {} is not base64: {err}", raw.seq)))?; + Ok(Frame { + bytes, + full: raw.full, + seq: raw.seq, + geometry: Geometry::new(raw.width, raw.height), + }) + } +} + +/// The reading half: repaints, in order, or an error. +#[derive(Debug)] +pub struct Frames { + reader: BufReader, + sequence: Sequence, + _process: Arc, +} + +impl Frames { + /// Block until the next repaint arrives. + /// + /// `Ok(None)` means herdr closed the stream cleanly — the session ended, or + /// something else took it over. That is an outcome, not a failure. + pub fn next_frame(&mut self) -> Result> { + loop { + let mut line = String::new(); + if self.reader.read_line(&mut line)? == 0 { + return Ok(None); + } + if line.trim().is_empty() { + continue; + } + let message: StreamMessage = serde_json::from_str(&line) + .map_err(|err| Error::Protocol(format!("unreadable frame: {err}")))?; + return match message { + StreamMessage::Closed(_) => Ok(None), + StreamMessage::Frame(raw) => self.sequence.accept(raw).map(Some), + }; + } + } +} + +/// The writing half: keystrokes and geometry. +#[derive(Debug)] +pub struct Input { + stdin: ChildStdin, + _process: Arc, +} + +impl Input { + /// Write raw bytes to the session's PTY. + pub fn send(&mut self, bytes: &[u8]) -> Result<()> { + self.write(&StreamCommand::Input { bytes: BASE64.encode(bytes) }) + } + + /// Resize the PTY, which delivers `SIGWINCH` to whatever is running in it. + /// + /// The next frame after this will be a full repaint at the new size. + pub fn resize(&mut self, geometry: Geometry) -> Result<()> { + self.write(&StreamCommand::Resize { cols: geometry.cols, rows: geometry.rows }) + } + + /// Detach cleanly, leaving the session running for the next attach. + pub fn release(&mut self) -> Result<()> { + self.write(&StreamCommand::Release) + } + + fn write(&mut self, command: &StreamCommand) -> Result<()> { + let mut line = serde_json::to_vec(command) + .map_err(|err| Error::Protocol(format!("cannot encode a stream command: {err}")))?; + line.push(b'\n'); + self.stdin.write_all(&line)?; + self.stdin.flush()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + //! The frame contract is the part worth testing without a backend. + //! Attaching for real needs a live daemon and lives in the ignored smoke + //! tests. + + use super::*; + + fn raw(seq: u64, full: bool) -> RawFrame { + RawFrame { bytes: BASE64.encode(b"hi"), full, seq, width: 80, height: 24 } + } + + #[test] + fn a_stream_must_open_with_a_full_repaint() { + let err = Sequence::default().accept(raw(0, false)).expect_err("a diff cannot be first"); + assert!(err.to_string().contains("full repaint"), "{err}"); + } + + #[test] + fn frames_in_order_are_accepted_and_decoded() { + let mut sequence = Sequence::default(); + let first = sequence.accept(raw(0, true)).expect("first frame"); + assert_eq!(first.bytes, b"hi"); + assert_eq!(first.geometry, Geometry::new(80, 24)); + sequence.accept(raw(1, false)).expect("second frame"); + sequence.accept(raw(2, false)).expect("third frame"); + } + + #[test] + fn a_gap_is_an_error_and_not_a_wrong_screen() { + let mut sequence = Sequence::default(); + sequence.accept(raw(0, true)).expect("first frame"); + let err = sequence.accept(raw(2, false)).expect_err("frame 1 never arrived"); + assert!(err.to_string().contains("frame 2 arrived where 1 was due"), "{err}"); + } + + #[test] + fn a_repeat_is_rejected_too() { + let mut sequence = Sequence::default(); + sequence.accept(raw(0, true)).expect("first frame"); + sequence.accept(raw(1, false)).expect("second frame"); + assert!(sequence.accept(raw(1, false)).is_err()); + } + + #[test] + fn a_stream_may_resume_from_any_sequence_number() { + // A re-attach does not restart herdr's counter, so the contract is + // "starts full, then contiguous" and not "starts at zero". + let mut sequence = Sequence::default(); + sequence.accept(raw(9_001, true)).expect("re-attach repaints in full"); + sequence.accept(raw(9_002, false)).expect("and continues from there"); + } + + #[test] + fn a_frame_that_is_not_base64_names_itself() { + let mut bad = raw(0, true); + bad.bytes = "not base64!!".to_owned(); + let err = Sequence::default().accept(bad).expect_err("undecodable"); + assert!(err.to_string().contains("frame 0 is not base64"), "{err}"); + } +} diff --git a/crates/zeddy-plugin-host/Cargo.toml b/crates/zeddy-plugin-host/Cargo.toml new file mode 100644 index 00000000..d3e3bf3f --- /dev/null +++ b/crates/zeddy-plugin-host/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "zeddy-plugin-host" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +zeddy-plugin = { path = "../zeddy-plugin" } +gpui.workspace = true +libloading.workspace = true + +[dev-dependencies] +# `#[gpui::test]` needs an app context to run the loader against. +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/zeddy-plugin-host/src/lib.rs b/crates/zeddy-plugin-host/src/lib.rs new file mode 100644 index 00000000..98ff087e --- /dev/null +++ b/crates/zeddy-plugin-host/src/lib.rs @@ -0,0 +1,365 @@ +//! The only code in zeddy that loads foreign code. +//! +//! Both plugin tiers are discovered the same way — a directory with a +//! `zeddy-plugin.toml` in it — and both arrive at the app as the same thing: a +//! list of panes with a way to build each one. Everything above this crate sees +//! [`Loaded`] and never asks which tier a pane came from. +//! +//! # Discovery, not installation +//! +//! This crate reads what is already on disk. Fetching a plugin from Git, +//! building one from source, and deciding whether the user trusts it are +//! separate concerns and separate code; putting them here would mean the window +//! could not enumerate plugins without also being able to install them. +//! +//! # Native libraries are never unloaded +//! +//! A native plugin's GPUI views hold vtables that live in its library. Dropping +//! the library while a view is alive is a use-after-free, and there is no +//! reliable moment at which zeddy knows the last one is gone. So [`Native`] +//! leaks its [`libloading::Library`] deliberately: a reload brings a *new* +//! generation up and swaps it in, and the old code stays mapped until the +//! process exits. Memory is the cost, and it is the cheap side of that trade. + +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use zeddy_plugin::{ + Entry, Host, PaneKey, PaneSpec, PluginObject, Registrar, + manifest::{Invalid, Kind, Manifest}, +}; + +/// One plugin, loaded and activated. +pub struct Loaded { + pub manifest: Manifest, + pub dir: PathBuf, + pub panes: Vec, + tier: Tier, +} + +/// The tier-specific half of a loaded plugin — the only place the difference +/// between "native" and "web" is still visible. +enum Tier { + Native(Native), + Web { entry: PathBuf }, +} + +/// A loaded native library and the object it produced. +struct Native { + plugin: Box, + /// Kept for the life of the process. See the module comment. + _library: &'static libloading::Library, +} + +/// How a pane should be built, once something above decides to show it. +pub enum PaneSource<'a> { + /// Call into the plugin for a GPUI view, mounted directly in the tree. + Native(&'a mut dyn PluginObject), + /// Point a webview at this document. + Web(&'a Path), +} + +impl Loaded { + pub fn id(&self) -> &str { + &self.manifest.id + } + + pub fn kind(&self) -> Kind { + self.manifest.kind + } + + /// How to build one of this plugin's panes. + /// + /// `None` for a pane this plugin did not declare — which is what a stale + /// saved layout looks like after a plugin drops a pane. + pub fn pane(&mut self, key: &PaneKey) -> Option> { + if !self.panes.iter().any(|pane| &pane.key == key) { + return None; + } + Some(match &mut self.tier { + Tier::Native(native) => PaneSource::Native(native.plugin.as_mut()), + Tier::Web { entry } => PaneSource::Web(entry.as_path()), + }) + } +} + +/// A plugin directory that could not be loaded, kept so Settings can say why +/// rather than silently showing one fewer plugin. +#[derive(Debug, Clone)] +pub struct Rejected { + pub dir: PathBuf, + pub why: String, +} + +/// Everything found in one scan. +#[derive(Default)] +pub struct Catalog { + /// Loaded plugins, by id. A `BTreeMap` so the sidebar's order is the same + /// on every launch rather than the order the filesystem happened to answer. + pub loaded: BTreeMap, + pub rejected: Vec, +} + +impl Catalog { + /// Every pane every loaded plugin contributes, in a stable order. + pub fn panes(&self) -> Vec<&PaneSpec> { + self.loaded.values().flat_map(|plugin| plugin.panes.iter()).collect() + } + + pub fn get_mut(&mut self, plugin: &str) -> Option<&mut Loaded> { + self.loaded.get_mut(plugin) + } +} + +/// Where plugins and their data live. +#[derive(Debug, Clone)] +pub struct Paths { + /// One directory per plugin id, each with a `zeddy-plugin.toml`. + pub installed: PathBuf, + /// One directory per plugin id, owned by the plugin and never by zeddy. + pub data: PathBuf, +} + +impl Paths { + pub fn under(root: impl Into) -> Self { + let root = root.into(); + Self { installed: root.join("plugins"), data: root.join("plugin-data") } + } +} + +/// Load every plugin under `paths.installed`. +/// +/// One bad plugin is recorded and skipped, never fatal: a plugin that fails to +/// load must not be able to stop zeddy from opening. +pub fn load_all(paths: &Paths, cx: &mut gpui::App) -> Catalog { + let mut catalog = Catalog::default(); + let Ok(entries) = std::fs::read_dir(&paths.installed) else { + return catalog; + }; + + let mut dirs: Vec = + entries.flatten().map(|entry| entry.path()).filter(|path| path.is_dir()).collect(); + dirs.sort(); + + for dir in dirs { + match load_one(&dir, paths, cx) { + Ok(plugin) => { + catalog.loaded.insert(plugin.manifest.id.clone(), plugin); + } + Err(why) => catalog.rejected.push(Rejected { dir, why: why.to_string() }), + } + } + catalog +} + +/// Why one plugin directory was refused. +#[derive(Debug)] +pub enum LoadError { + Manifest(Invalid), + /// The directory's name is not the manifest's id. They must agree, because + /// the directory name is how a saved layout finds a plugin without parsing + /// every manifest. + IdMismatch { + dir: String, + manifest: String, + }, + MissingFile(PathBuf), + /// `dlopen` failed, or the library had no entry point. + Library(String), +} + +impl std::fmt::Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Manifest(invalid) => write!(f, "{invalid}"), + Self::IdMismatch { dir, manifest } => { + write!(f, "directory `{dir}` holds a plugin with id `{manifest}`") + } + Self::MissingFile(path) => write!(f, "{} is missing", path.display()), + Self::Library(why) => write!(f, "cannot load the plugin library: {why}"), + } + } +} + +impl std::error::Error for LoadError {} + +fn load_one(dir: &Path, paths: &Paths, cx: &mut gpui::App) -> Result { + let manifest = Manifest::read(dir).map_err(LoadError::Manifest)?; + + let dir_name = dir.file_name().unwrap_or_default().to_string_lossy(); + if dir_name != manifest.id { + return Err(LoadError::IdMismatch { + dir: dir_name.into_owned(), + manifest: manifest.id.clone(), + }); + } + + let data_dir = paths.data.join(&manifest.id); + std::fs::create_dir_all(&data_dir).ok(); + let host = Host { data_dir, plugin_dir: dir.to_owned() }; + + let (tier, panes) = match manifest.kind { + Kind::Native => { + let filename = manifest + .library_filename() + .ok_or_else(|| LoadError::MissingFile(dir.join("")))?; + let library_path = dir.join(&filename); + if !library_path.is_file() { + return Err(LoadError::MissingFile(library_path)); + } + let (native, panes) = open_native(&library_path, &manifest.id, host, cx)?; + (Tier::Native(native), panes) + } + Kind::Web => { + let entry = dir.join(manifest.entry.as_deref().unwrap_or("index.html")); + if !entry.is_file() { + return Err(LoadError::MissingFile(entry)); + } + // A web plugin's panes come from its manifest rather than from + // running its code: zeddy must be able to list them without + // starting a webview. + let panes = vec![PaneSpec { + key: PaneKey::new(manifest.id.clone(), "main"), + title: manifest.name.clone(), + }]; + (Tier::Web { entry }, panes) + } + }; + + Ok(Loaded { manifest, dir: dir.to_owned(), panes, tier }) +} + +fn open_native( + path: &Path, + id: &str, + host: Host, + cx: &mut gpui::App, +) -> Result<(Native, Vec), LoadError> { + // SAFETY: loading a library runs its initialisers, which is arbitrary + // native code. That is the documented trust model of the native tier — the + // manifest's `native_abi` has already been checked to match this build, and + // nothing beyond that is verifiable in-process. + let library = unsafe { libloading::Library::new(path) } + .map_err(|err| LoadError::Library(format!("{}: {err}", path.display())))?; + // Leaked on purpose: see the module comment. + let library: &'static libloading::Library = Box::leak(Box::new(library)); + + // SAFETY: the symbol's type is the contract in `zeddy-plugin`, and the ABI + // check above is what makes that contract the same one this build compiled. + let entry: libloading::Symbol<'static, Entry> = + unsafe { library.get(zeddy_plugin::ENTRY_SYMBOL) } + .map_err(|err| LoadError::Library(format!("no zeddy_plugin_entry: {err}")))?; + + // SAFETY: as above. + let mut plugin = unsafe { entry(host, cx) }; + + if plugin.id() != id { + return Err(LoadError::IdMismatch { dir: id.to_owned(), manifest: plugin.id().to_owned() }); + } + + let mut registrar = Registrar::new(id); + plugin.activate(&mut registrar, cx); + let panes = registrar.panes().to_vec(); + + Ok((Native { plugin, _library: library }, panes)) +} + +#[cfg(test)] +mod tests { + //! Loading a real native library needs one to have been built, so these + //! cover discovery, validation, and the web tier. The native path is + //! exercised end to end by `plugins/hello` in the app's own tests. + + use super::*; + + fn paths() -> (tempfile::TempDir, Paths) { + let tmp = tempfile::tempdir().expect("tempdir"); + let paths = Paths::under(tmp.path()); + std::fs::create_dir_all(&paths.installed).expect("installed dir"); + (tmp, paths) + } + + fn write_web(paths: &Paths, id: &str, dir_name: &str) -> PathBuf { + let dir = paths.installed.join(dir_name); + std::fs::create_dir_all(&dir).expect("plugin dir"); + std::fs::write( + dir.join("zeddy-plugin.toml"), + format!( + "manifest_version = 1\nid = \"{id}\"\nname = \"Notes\"\n\ + version = \"0.1.0\"\nkind = \"web\"\nentry = \"index.html\"\n" + ), + ) + .expect("manifest"); + std::fs::write(dir.join("index.html"), "

hi

").expect("entry"); + dir + } + + #[gpui::test] + fn a_web_plugin_is_discovered_and_contributes_a_pane(cx: &mut gpui::TestAppContext) { + let (_tmp, paths) = paths(); + write_web(&paths, "com.example.notes", "com.example.notes"); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert_eq!(catalog.loaded.len(), 1); + assert_eq!(catalog.panes().len(), 1); + assert_eq!(catalog.panes()[0].title, "Notes"); + } + + #[gpui::test] + fn a_directory_whose_name_disagrees_with_the_manifest_is_rejected( + cx: &mut gpui::TestAppContext, + ) { + let (_tmp, paths) = paths(); + write_web(&paths, "com.example.notes", "notes"); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert!(catalog.loaded.is_empty()); + assert_eq!(catalog.rejected.len(), 1); + assert!(catalog.rejected[0].why.contains("com.example.notes"), "{:?}", catalog.rejected[0]); + } + + #[gpui::test] + fn a_web_plugin_with_no_entry_document_is_rejected(cx: &mut gpui::TestAppContext) { + let (_tmp, paths) = paths(); + let dir = write_web(&paths, "com.example.notes", "com.example.notes"); + std::fs::remove_file(dir.join("index.html")).expect("remove entry"); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert_eq!(catalog.rejected.len(), 1); + assert!(catalog.rejected[0].why.contains("index.html")); + } + + #[gpui::test] + fn one_bad_plugin_does_not_stop_the_others_loading(cx: &mut gpui::TestAppContext) { + let (_tmp, paths) = paths(); + write_web(&paths, "com.example.notes", "com.example.notes"); + let broken = paths.installed.join("com.example.broken"); + std::fs::create_dir_all(&broken).expect("dir"); + std::fs::write(broken.join("zeddy-plugin.toml"), "not toml {{{").expect("manifest"); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert_eq!(catalog.loaded.len(), 1); + assert_eq!(catalog.rejected.len(), 1); + } + + #[gpui::test] + fn a_pane_a_plugin_never_declared_has_no_source(cx: &mut gpui::TestAppContext) { + let (_tmp, paths) = paths(); + write_web(&paths, "com.example.notes", "com.example.notes"); + + let mut catalog = cx.update(|cx| load_all(&paths, cx)); + let plugin = catalog.get_mut("com.example.notes").expect("loaded"); + assert!(plugin.pane(&PaneKey::new("com.example.notes", "main")).is_some()); + assert!(plugin.pane(&PaneKey::new("com.example.notes", "gone")).is_none()); + } + + #[gpui::test] + fn a_missing_plugin_directory_is_an_empty_catalog_not_an_error(cx: &mut gpui::TestAppContext) { + let tmp = tempfile::tempdir().expect("tempdir"); + let paths = Paths::under(tmp.path().join("nothing-here")); + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert!(catalog.loaded.is_empty() && catalog.rejected.is_empty()); + } +} diff --git a/crates/zeddy-plugin/Cargo.toml b/crates/zeddy-plugin/Cargo.toml new file mode 100644 index 00000000..7c0b259e --- /dev/null +++ b/crates/zeddy-plugin/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "zeddy-plugin" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# The framework, never the platform backend. A plugin that linked +# `gpui_platform` would register a second application with the window server. +gpui.workspace = true +serde.workspace = true +toml.workspace = true diff --git a/crates/zeddy-plugin/src/lib.rs b/crates/zeddy-plugin/src/lib.rs new file mode 100644 index 00000000..52390271 --- /dev/null +++ b/crates/zeddy-plugin/src/lib.rs @@ -0,0 +1,231 @@ +//! The contract a zeddy plugin is written against. +//! +//! Both tiers contribute the same thing — a **pane**: a titled surface zeddy +//! can show in the sidebar or as a tab. Nothing above the plugin host cares +//! which tier a pane came from, which is what lets a web plugin and a native +//! one sit side by side in the same tab strip. +//! +//! # The native tier +//! +//! One trait, one macro, one manifest. A native plugin's view is an ordinary +//! GPUI [`AnyView`](gpui::AnyView) mounted directly in zeddy's element tree, so +//! scrolling, resizing, focus, input, and painting use exactly the same frame +//! path as a built-in view. There is no webview, no Wasm runtime, no synthetic +//! window, no display-list replay, and no UI RPC layer. +//! +//! ```ignore +//! use zeddy_plugin::{Host, Plugin, Registrar, gpui, register}; +//! +//! struct StarMap; +//! +//! impl Plugin for StarMap { +//! const ID: &'static str = "com.example.starmap"; +//! +//! fn new(_: Host, _: &mut gpui::App) -> Self { +//! Self +//! } +//! +//! fn activate(&mut self, registrar: &mut Registrar, _: &mut gpui::App) { +//! registrar.add_pane("map", "Star map"); +//! } +//! +//! fn view(&mut self, _: &PaneKey, _: &mut gpui::Window, cx: &mut gpui::App) -> gpui::AnyView { +//! cx.new(|_| MapView::default()).into() +//! } +//! } +//! +//! register!(StarMap); +//! ``` +//! +//! That openness is also the trust model. A native plugin may use raw GPUI, any +//! compatible crate, the filesystem, processes, and the network — installing +//! one is installing native code, and no sandbox is claimed. Install +//! repositories you trust, or use the web tier, which is sandboxed. +//! +//! # The web tier +//! +//! A web plugin has no Rust in it at all: a manifest and an entry document. +//! zeddy hosts it in an OS webview and hands it the same pane slot. See +//! [`manifest::Kind::Web`]. + +#![forbid(unsafe_code)] + +pub mod manifest; + +pub use gpui; +pub use manifest::{Kind, Manifest}; + +use std::path::PathBuf; + +/// A pane a plugin contributes, addressed by the plugin's id and its own key. +/// +/// Two plugins may both call a pane `"main"`; the id keeps them apart, and the +/// pair is stable across restarts so a saved layout can name one. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PaneKey { + pub plugin: String, + pub key: String, +} + +impl PaneKey { + pub fn new(plugin: impl Into, key: impl Into) -> Self { + Self { plugin: plugin.into(), key: key.into() } + } +} + +/// A pane's declaration: what the sidebar and the tab strip put on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneSpec { + pub key: PaneKey, + pub title: String, +} + +/// What a plugin declares during [`Plugin::activate`]. +/// +/// Declaring is separate from building. Activation runs once, and zeddy calls +/// [`Plugin::view`] only when a pane is actually shown — so a plugin that +/// contributes ten panes costs ten strings until one is opened. +#[derive(Debug, Default)] +pub struct Registrar { + plugin: String, + panes: Vec, +} + +impl Registrar { + pub fn new(plugin: impl Into) -> Self { + Self { plugin: plugin.into(), panes: Vec::new() } + } + + /// Contribute a pane. `key` is this plugin's own name for it. + pub fn add_pane(&mut self, key: impl Into, title: impl Into) -> &mut Self { + self.panes + .push(PaneSpec { key: PaneKey::new(self.plugin.clone(), key), title: title.into() }); + self + } + + pub fn panes(&self) -> &[PaneSpec] { + &self.panes + } +} + +/// What zeddy hands a plugin at construction. +/// +/// Deliberately small. Every field here is a promise zeddy has to keep across +/// versions, so the contract grows only when a real plugin needs it to. +#[derive(Debug, Clone)] +pub struct Host { + /// This plugin's private directory. It survives disablement, replacement, + /// and upgrade, and is removed only when the user asks for it to be. + pub data_dir: PathBuf, + /// The directory the plugin itself was installed into. Read-only by + /// convention: a reload replaces it. + pub plugin_dir: PathBuf, +} + +/// A native plugin. +pub trait Plugin: Sized + 'static { + /// The reverse-DNS id, which must equal the manifest's. + const ID: &'static str; + + fn new(host: Host, cx: &mut gpui::App) -> Self; + + /// Declare what this plugin contributes. Called once, at load. + fn activate(&mut self, registrar: &mut Registrar, cx: &mut gpui::App); + + /// Build the view for one of the panes declared in [`Plugin::activate`]. + /// + /// Called when the pane is first shown, and again after a reload. + fn view( + &mut self, + pane: &PaneKey, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> gpui::AnyView; +} + +/// The object-safe face of [`Plugin`], which is what crosses the library +/// boundary. Written for you by [`register!`]; never implemented by hand. +pub trait PluginObject { + fn id(&self) -> &str; + fn activate(&mut self, registrar: &mut Registrar, cx: &mut gpui::App); + fn view( + &mut self, + pane: &PaneKey, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> gpui::AnyView; +} + +impl PluginObject for P { + fn id(&self) -> &str { + P::ID + } + + fn activate(&mut self, registrar: &mut Registrar, cx: &mut gpui::App) { + Plugin::activate(self, registrar, cx) + } + + fn view( + &mut self, + pane: &PaneKey, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> gpui::AnyView { + Plugin::view(self, pane, window, cx) + } +} + +/// The exported symbol every native plugin defines, and the only symbol zeddy +/// looks up. +pub const ENTRY_SYMBOL: &[u8] = b"zeddy_plugin_entry"; + +/// The signature of [`ENTRY_SYMBOL`]. +/// +/// `Host` is an ordinary Rust type and is not `repr(C)`, which is exactly what +/// the `improper_ctypes_definitions` lint is for. It is silenced deliberately: +/// this boundary is Rust-to-Rust, and what makes it sound is +/// [`manifest::NATIVE_ABI`] — both sides are compiled by the same toolchain +/// against the same `zeddy-plugin`, and a mismatch is refused at load rather +/// than survived. A `repr(C)` shim here would add a conversion without adding a +/// guarantee, because `Box` and `gpui::App` cannot be made +/// C-compatible anyway. +#[allow(improper_ctypes_definitions)] +pub type Entry = unsafe extern "C" fn(Host, &mut gpui::App) -> Box; + +/// Export a [`Plugin`] as a loadable library. +/// +/// One macro invocation is the entire boilerplate of a native plugin. +#[macro_export] +macro_rules! register { + ($plugin:ty) => { + #[unsafe(no_mangle)] + pub extern "C" fn zeddy_plugin_entry( + host: $crate::Host, + cx: &mut $crate::gpui::App, + ) -> ::std::boxed::Box { + ::std::boxed::Box::new(<$plugin as $crate::Plugin>::new(host, cx)) + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_registrar_stamps_every_pane_with_its_plugin() { + let mut registrar = Registrar::new("com.example.starmap"); + registrar.add_pane("map", "Star map").add_pane("legend", "Legend"); + assert_eq!(registrar.panes().len(), 2); + assert!(registrar.panes().iter().all(|p| p.key.plugin == "com.example.starmap")); + } + + #[test] + fn two_plugins_may_use_the_same_pane_key() { + let mut one = Registrar::new("a"); + one.add_pane("main", "One"); + let mut two = Registrar::new("b"); + two.add_pane("main", "Two"); + assert_ne!(one.panes()[0].key, two.panes()[0].key); + } +} diff --git a/crates/zeddy-plugin/src/manifest.rs b/crates/zeddy-plugin/src/manifest.rs new file mode 100644 index 00000000..5197e430 --- /dev/null +++ b/crates/zeddy-plugin/src/manifest.rs @@ -0,0 +1,244 @@ +//! `zeddy-plugin.toml` — the one file both plugin tiers have in common. +//! +//! A plugin is a directory with this file in it. What the directory *contains* +//! beyond the manifest is what makes it native or web, and the manifest's +//! [`Kind`] is what says which. + +use std::path::Path; + +use serde::Deserialize; + +/// The manifest version this build reads. Bumped when a field changes meaning. +pub const MANIFEST_VERSION: u32 = 1; + +/// The native ABI this build links. +/// +/// A native plugin passes Rust and GPUI objects across a dynamic-library +/// boundary, so its `native_abi` must match zeddy's *exactly*. There is no +/// compatibility range and there is not going to be one: a mismatch is a +/// vtable from a different compilation, and the failure mode is a crash rather +/// than a wrong answer. +pub const NATIVE_ABI: u32 = 1; + +/// Which tier a plugin belongs to. +/// +/// The two tiers exist because "anyone can author one" and "fast enough to +/// paint a star-map at 120fps" are different requirements, and one runtime +/// cannot honestly be both. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// A `cdylib` mounted directly in zeddy's element tree. Its view is an + /// ordinary GPUI view: same frame path, same input, same scrolling as a + /// built-in. Installing one is installing native code, and the trust model + /// says so out loud. + Native, + /// HTML and JavaScript in an OS webview. Sandboxed, hot-reloadable, + /// authorable by anyone who has written a web page — and a frame behind + /// native, because it is composited rather than painted. + Web, +} + +/// A parsed `zeddy-plugin.toml`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Manifest { + pub manifest_version: u32, + /// Reverse-DNS, and the identity everything else keys off: the install + /// directory, the data directory, the pane ids. Two plugins with the same + /// id are the same plugin at different versions. + pub id: String, + pub name: String, + pub version: String, + pub kind: Kind, + /// Native only: the Cargo library stem. zeddy appends the platform's + /// extension, so one manifest covers `.dylib`, `.so`, and `.dll`. + #[serde(default)] + pub library: Option, + /// Native only, and required there. + #[serde(default)] + pub native_abi: Option, + /// Web only: the entry document, relative to the plugin directory. + #[serde(default)] + pub entry: Option, +} + +/// Why a manifest was refused. +#[derive(Debug, Clone, PartialEq)] +pub enum Invalid { + Unreadable(String), + Malformed(String), + /// The manifest is from a different generation of the format. + ManifestVersion { + found: u32, + }, + /// A native plugin compiled against a different zeddy. + NativeAbi { + found: Option, + }, + /// A field the manifest's own `kind` requires is missing. + Missing { + field: &'static str, + kind: Kind, + }, + BadId(String), +} + +impl std::fmt::Display for Invalid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unreadable(why) => write!(f, "cannot read zeddy-plugin.toml: {why}"), + Self::Malformed(why) => write!(f, "zeddy-plugin.toml is not valid: {why}"), + Self::ManifestVersion { found } => { + write!(f, "manifest_version is {found}; this zeddy reads {MANIFEST_VERSION}") + } + Self::NativeAbi { found } => match found { + Some(found) => write!(f, "native_abi is {found}; this zeddy links {NATIVE_ABI}"), + None => write!(f, "a native plugin must declare native_abi = {NATIVE_ABI}"), + }, + Self::Missing { field, kind } => { + write!(f, "a {kind:?} plugin must declare `{field}`") + } + Self::BadId(id) => write!(f, "`{id}` is not a usable plugin id"), + } + } +} + +impl std::error::Error for Invalid {} + +impl Manifest { + /// Read and validate the manifest in a plugin directory. + /// + /// Validation is total: a manifest that comes back `Ok` has everything its + /// own tier needs, so nothing downstream re-checks a field. + pub fn read(dir: &Path) -> Result { + let path = dir.join("zeddy-plugin.toml"); + let text = std::fs::read_to_string(&path) + .map_err(|err| Invalid::Unreadable(format!("{}: {err}", path.display())))?; + let manifest: Self = + toml::from_str(&text).map_err(|err| Invalid::Malformed(err.to_string()))?; + manifest.validate()?; + Ok(manifest) + } + + fn validate(&self) -> Result<(), Invalid> { + if self.manifest_version != MANIFEST_VERSION { + return Err(Invalid::ManifestVersion { found: self.manifest_version }); + } + if !is_usable_id(&self.id) { + return Err(Invalid::BadId(self.id.clone())); + } + match self.kind { + Kind::Native => { + if self.native_abi != Some(NATIVE_ABI) { + return Err(Invalid::NativeAbi { found: self.native_abi }); + } + if self.library.is_none() { + return Err(Invalid::Missing { field: "library", kind: self.kind }); + } + } + Kind::Web => { + if self.entry.is_none() { + return Err(Invalid::Missing { field: "entry", kind: self.kind }); + } + } + } + Ok(()) + } + + /// The library filename this platform expects, for a native plugin. + pub fn library_filename(&self) -> Option { + let stem = self.library.as_deref()?; + Some(if cfg!(target_os = "windows") { + format!("{stem}.dll") + } else if cfg!(target_os = "macos") { + format!("lib{stem}.dylib") + } else { + format!("lib{stem}.so") + }) + } +} + +/// An id has to be safe to use as a directory name, because it is used as one. +fn is_usable_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + && !id.starts_with('.') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> Result { + let manifest: Manifest = + toml::from_str(toml).map_err(|e| Invalid::Malformed(e.to_string()))?; + manifest.validate().map(|()| manifest) + } + + const NATIVE: &str = r#" + manifest_version = 1 + id = "com.example.starmap" + name = "Star map" + version = "0.1.0" + kind = "native" + library = "starmap" + native_abi = 1 + "#; + + const WEB: &str = r#" + manifest_version = 1 + id = "com.example.notes" + name = "Notes" + version = "0.1.0" + kind = "web" + entry = "index.html" + "#; + + #[test] + fn both_tiers_parse() { + assert_eq!(parse(NATIVE).expect("native").kind, Kind::Native); + assert_eq!(parse(WEB).expect("web").kind, Kind::Web); + } + + #[test] + fn a_native_plugin_from_another_abi_is_refused() { + let wrong = NATIVE.replace("native_abi = 1", "native_abi = 2"); + assert_eq!(parse(&wrong), Err(Invalid::NativeAbi { found: Some(2) })); + } + + #[test] + fn a_native_plugin_without_an_abi_is_refused_rather_than_assumed() { + let missing = NATIVE.replace("native_abi = 1", ""); + assert_eq!(parse(&missing), Err(Invalid::NativeAbi { found: None })); + } + + #[test] + fn each_tier_requires_only_its_own_fields() { + // A web plugin needs no ABI, and a native plugin needs no entry point. + assert!(parse(WEB).is_ok()); + assert!(parse(NATIVE).is_ok()); + let no_entry = WEB.replace("entry = \"index.html\"", ""); + assert_eq!(parse(&no_entry), Err(Invalid::Missing { field: "entry", kind: Kind::Web })); + } + + #[test] + fn an_id_that_could_escape_its_directory_is_refused() { + for bad in ["", ".", "../etc", "a/b", ".hidden"] { + let toml = NATIVE.replace("com.example.starmap", bad); + assert!(matches!(parse(&toml), Err(Invalid::BadId(_))), "accepted {bad:?}"); + } + } + + #[test] + fn the_library_filename_follows_the_platform() { + let name = parse(NATIVE).expect("native").library_filename().expect("a name"); + assert!(name.contains("starmap")); + assert!(name.ends_with(std::env::consts::DLL_SUFFIX)); + } + + #[test] + fn a_web_plugin_has_no_library_filename() { + assert_eq!(parse(WEB).expect("web").library_filename(), None); + } +} diff --git a/crates/zeddy-vt/Cargo.toml b/crates/zeddy-vt/Cargo.toml new file mode 100644 index 00000000..be887158 --- /dev/null +++ b/crates/zeddy-vt/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zeddy-vt" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +alacritty_terminal.workspace = true diff --git a/crates/zeddy-vt/src/lib.rs b/crates/zeddy-vt/src/lib.rs new file mode 100644 index 00000000..8af68434 --- /dev/null +++ b/crates/zeddy-vt/src/lib.rs @@ -0,0 +1,354 @@ +//! zeddy's only VT boundary. +//! +//! Bytes go in, a [`Screen`] comes out. That is the whole contract, and it is +//! the whole reason this crate exists: the renderer above it never sees an +//! escape sequence, and swapping the parser underneath it is a change to one +//! file rather than to the window. +//! +//! # Why alacritty's core +//! +//! It is the parser Zed's own terminal uses, and zeddy is built on Zed's +//! frontend. Taking the same one means the grid semantics the renderer assumes +//! and the grid semantics the parser produces already agree — and, unlike +//! libghostty-vt, it needs no Zig in the build. +//! +//! # Snapshots, not references +//! +//! [`Terminal::screen`] copies. A borrowed grid would be faster and would tie +//! the render pass to the lifetime of the emulator, which is owned by a +//! different thread than the one painting. At the sizes a terminal actually +//! runs — a few thousand cells — the copy is not what makes a frame slow, and +//! the freedom is worth more than the memcpy. + +#![forbid(unsafe_code)] + +use std::sync::{Arc, Mutex}; + +use alacritty_terminal::{ + event::{Event, EventListener}, + grid::Dimensions, + index::{Column, Line, Point}, + term::{Config, cell::Flags}, + vte::ansi::{Color as AnsiColor, NamedColor, Processor}, +}; + +/// A terminal grid, in cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Size { + pub cols: u16, + pub rows: u16, +} + +impl Size { + pub fn new(cols: u16, rows: u16) -> Self { + Self { cols: cols.max(1), rows: rows.max(1) } + } +} + +impl Default for Size { + fn default() -> Self { + Self::new(80, 24) + } +} + +/// `alacritty_terminal` asks for dimensions through a trait, so [`Size`] answers. +impl Dimensions for Size { + fn total_lines(&self) -> usize { + self.rows as usize + } + + fn screen_lines(&self) -> usize { + self.rows as usize + } + + fn columns(&self) -> usize { + self.cols as usize + } +} + +/// A cell's colour, in the terms the theme resolves rather than in RGB. +/// +/// `Default` is deliberately not "black": which colour the default foreground +/// is belongs to the theme, and resolving it here would hard-code one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Color { + /// The theme's default foreground or background for this position. + Default, + /// One of the sixteen ANSI colours, or the 256-colour cube. + Indexed(u8), + /// A true-colour value the program asked for exactly. + Rgb(u8, u8, u8), +} + +/// How a cell is drawn, beyond its colours. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Style { + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub dim: bool, + /// Foreground and background swap. Resolved by the renderer, because only + /// it knows what [`Color::Default`] actually is. + pub inverse: bool, +} + +/// One cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cell { + pub ch: char, + pub fg: Color, + pub bg: Color, + pub style: Style, +} + +impl Default for Cell { + fn default() -> Self { + Self { ch: ' ', fg: Color::Default, bg: Color::Default, style: Style::default() } + } +} + +/// Where the cursor is, when it is visible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cursor { + pub col: u16, + pub row: u16, +} + +/// A whole screen, ready to paint, owing nothing to the emulator that made it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Screen { + pub size: Size, + /// `size.rows` rows of `size.cols` cells, top row first. + pub rows: Vec>, + pub cursor: Option, + /// The window title the program last set, if it set one. + pub title: Option, +} + +impl Screen { + /// The screen as plain text, one line per row, trailing blanks trimmed. + /// + /// Not a rendering path — this is what tests assert against and what a + /// plugin reading a session gets. + pub fn to_text(&self) -> String { + self.rows + .iter() + .map(|row| row.iter().map(|cell| cell.ch).collect::().trim_end().to_owned()) + .collect::>() + .join("\n") + } +} + +/// The emulator reports the window title as an *event*, not as grid state, so +/// something has to be listening for one to be readable at all. +/// +/// Everything else the emulator emits — clipboard requests, colour queries, +/// PTY writebacks — is a reply zeddy does not owe: herdr owns the PTY, and a +/// reply written here would never reach it. They are dropped deliberately. +#[derive(Debug, Clone, Default)] +struct TitleSink(Arc>>); + +impl EventListener for TitleSink { + fn send_event(&self, event: Event) { + match event { + Event::Title(title) => *self.0.lock().expect("title mutex") = Some(title), + Event::ResetTitle => *self.0.lock().expect("title mutex") = None, + _ => {} + } + } +} + +/// A terminal emulator fed by [`Terminal::feed`]. +pub struct Terminal { + term: alacritty_terminal::Term, + parser: Processor, + size: Size, + title: TitleSink, +} + +impl Terminal { + pub fn new(size: Size) -> Self { + // No scrollback. herdr's frame stream sends the viewport and has no way + // to move it back through history, so a scrollback buffer here would be + // a buffer nothing can ever scroll to. History comes from the control + // plane instead, and is a different rendering. + let config = Config { scrolling_history: 0, ..Config::default() }; + let title = TitleSink::default(); + Self { + term: alacritty_terminal::Term::new(config, &size, title.clone()), + parser: Processor::new(), + size, + title, + } + } + + pub fn size(&self) -> Size { + self.size + } + + /// Apply a repaint. Bytes must arrive in the order they were produced. + pub fn feed(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + } + + /// Re-run the grid at a new size. + /// + /// Whatever is driving this should expect a full repaint next: a resize + /// invalidates the diffs the previous frames were measured against. + pub fn resize(&mut self, size: Size) { + if size == self.size { + return; + } + self.size = size; + self.term.resize(size); + } + + /// Copy the current screen out. + pub fn screen(&self) -> Screen { + let grid = self.term.grid(); + let mut rows = Vec::with_capacity(self.size.rows as usize); + for line in 0..self.size.rows as i32 { + let mut cells = Vec::with_capacity(self.size.cols as usize); + for column in 0..self.size.cols as usize { + cells.push(convert(&grid[Point::new(Line(line), Column(column))])); + } + rows.push(cells); + } + + let cursor = { + let point = grid.cursor.point; + let visible = + self.term.mode().contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); + visible.then(|| Cursor { col: point.column.0 as u16, row: point.line.0.max(0) as u16 }) + }; + + Screen { + size: self.size, + rows, + cursor, + title: self.title.0.lock().expect("title mutex").clone(), + } + } +} + +impl std::fmt::Debug for Terminal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Terminal").field("size", &self.size).finish_non_exhaustive() + } +} + +fn convert(cell: &alacritty_terminal::term::cell::Cell) -> Cell { + let flags = cell.flags; + Cell { + ch: cell.c, + fg: color(cell.fg), + bg: color(cell.bg), + style: Style { + bold: flags.contains(Flags::BOLD), + italic: flags.contains(Flags::ITALIC), + underline: flags.intersects(Flags::ALL_UNDERLINES), + dim: flags.contains(Flags::DIM), + inverse: flags.contains(Flags::INVERSE), + }, + } +} + +fn color(color: AnsiColor) -> Color { + match color { + AnsiColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b), + AnsiColor::Indexed(index) => Color::Indexed(index), + // The named slots that mean "whatever the theme says" stay `Default`; + // the sixteen real ANSI names become their indices, which is what a + // palette is indexed by anyway. + AnsiColor::Named( + NamedColor::Foreground + | NamedColor::Background + | NamedColor::Cursor + | NamedColor::DimForeground + | NamedColor::BrightForeground, + ) => Color::Default, + AnsiColor::Named(named) => Color::Indexed(named as u8), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screen_of(bytes: &[u8]) -> Screen { + let mut term = Terminal::new(Size::new(20, 3)); + term.feed(bytes); + term.screen() + } + + #[test] + fn plain_text_lands_on_the_grid() { + assert_eq!(screen_of(b"hello").to_text().lines().next(), Some("hello")); + } + + #[test] + fn a_screen_is_always_exactly_its_size() { + let screen = screen_of(b"hi"); + assert_eq!(screen.rows.len(), 3); + assert!(screen.rows.iter().all(|row| row.len() == 20)); + } + + #[test] + fn sgr_colours_reach_the_cells() { + let screen = screen_of(b"\x1b[31mred"); + assert_eq!(screen.rows[0][0].fg, Color::Indexed(NamedColor::Red as u8)); + assert_eq!(screen.rows[0][0].bg, Color::Default, "background was never set"); + } + + #[test] + fn true_colour_survives_as_true_colour() { + let screen = screen_of(b"\x1b[38;2;10;20;30mx"); + assert_eq!(screen.rows[0][0].fg, Color::Rgb(10, 20, 30)); + } + + #[test] + fn attributes_are_carried_not_flattened_into_colour() { + let screen = screen_of(b"\x1b[1;3;4mstyled"); + let style = screen.rows[0][0].style; + assert!(style.bold && style.italic && style.underline); + } + + #[test] + fn cursor_addressing_moves_the_cursor() { + let screen = screen_of(b"\x1b[2;5H"); + assert_eq!(screen.cursor, Some(Cursor { col: 4, row: 1 })); + } + + #[test] + fn a_hidden_cursor_is_absent_rather_than_placed_somewhere() { + assert_eq!(screen_of(b"\x1b[?25l").cursor, None); + } + + #[test] + fn an_osc_title_is_picked_up() { + assert_eq!(screen_of(b"\x1b]0;a session\x07").title.as_deref(), Some("a session")); + } + + #[test] + fn feeding_a_repaint_in_two_writes_is_the_same_as_one() { + let mut split = Terminal::new(Size::new(20, 3)); + split.feed(b"\x1b[3"); + split.feed(b"1mred"); + assert_eq!(split.screen(), screen_of(b"\x1b[31mred")); + } + + #[test] + fn resizing_changes_the_shape_of_the_next_snapshot() { + let mut term = Terminal::new(Size::new(20, 3)); + term.resize(Size::new(40, 10)); + let screen = term.screen(); + assert_eq!(screen.size, Size::new(40, 10)); + assert_eq!(screen.rows.len(), 10); + assert_eq!(screen.rows[0].len(), 40); + } + + #[test] + fn a_zero_sized_grid_is_never_handed_to_the_emulator() { + assert_eq!(Size::new(0, 0), Size::new(1, 1)); + } +} diff --git a/crates/zeddy/Cargo.toml b/crates/zeddy/Cargo.toml new file mode 100644 index 00000000..a1201765 --- /dev/null +++ b/crates/zeddy/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "zeddy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +default-run = "zeddy" + +[dependencies] +zeddy-herdr = { path = "../zeddy-herdr" } +zeddy-plugin = { path = "../zeddy-plugin" } +zeddy-plugin-host = { path = "../zeddy-plugin-host" } +zeddy-vt = { path = "../zeddy-vt" } + +gpui.workspace = true +# The one crate that may name the platform backend. +gpui_platform.workspace = true +ui.workspace = true +theme.workspace = true + +anyhow.workspace = true +futures.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/zeddy/assets/icons/close.svg b/crates/zeddy/assets/icons/close.svg new file mode 100644 index 00000000..4fa33574 --- /dev/null +++ b/crates/zeddy/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/zeddy/assets/icons/menu.svg b/crates/zeddy/assets/icons/menu.svg new file mode 100644 index 00000000..81ad8bd9 --- /dev/null +++ b/crates/zeddy/assets/icons/menu.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/zeddy/assets/icons/plus.svg b/crates/zeddy/assets/icons/plus.svg new file mode 100644 index 00000000..01b34b79 --- /dev/null +++ b/crates/zeddy/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/zeddy/assets/icons/tab.svg b/crates/zeddy/assets/icons/tab.svg new file mode 100644 index 00000000..4b124f68 --- /dev/null +++ b/crates/zeddy/assets/icons/tab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/zeddy/build.rs b/crates/zeddy/build.rs new file mode 100644 index 00000000..c7c41357 --- /dev/null +++ b/crates/zeddy/build.rs @@ -0,0 +1,45 @@ +//! Places the vendored herdr beside the zeddy binary. +//! +//! zeddy resolves its backend by path — `/herdr` — +//! and never through `PATH`, so the executable has to actually be there. This +//! copies it, and fails the build if it is not vendored, because a zeddy that +//! builds and then cannot start a session is worse than one that does not build. +//! +//! This does not reach the network. Fetching is `vendor/herdr/fetch.sh`, run by +//! hand when the pin moves. + +use std::path::{Path, PathBuf}; + +fn main() { + let target = std::env::var("TARGET").expect("TARGET"); + let root = workspace_root(); + let vendored = root.join("vendor/herdr").join(&target).join("herdr"); + + println!("cargo::rerun-if-changed={}", vendored.display()); + + if !vendored.is_file() { + println!( + "cargo::error=no herdr for {target} at {}. Run `sh vendor/herdr/fetch.sh`.", + vendored.display() + ); + return; + } + + let beside = out_dir_binary_dir().join("herdr"); + if let Err(err) = std::fs::copy(&vendored, &beside) { + println!("cargo::error=cannot place herdr at {}: {err}", beside.display()); + } +} + +fn workspace_root() -> PathBuf { + // `CARGO_MANIFEST_DIR` is `crates/zeddy`; the workspace is two above it. + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + manifest.parent().and_then(Path::parent).expect("the workspace root").to_owned() +} + +/// Cargo gives a build script `OUT_DIR`, not the directory the binary lands in. +/// The binary directory is three levels up: `…//build/-/out`. +fn out_dir_binary_dir() -> PathBuf { + let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")); + out.ancestors().nth(3).expect("the profile directory").to_owned() +} diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs new file mode 100644 index 00000000..047d7589 --- /dev/null +++ b/crates/zeddy/src/app.rs @@ -0,0 +1,488 @@ +//! The root view: the sessions, the mode, the plugins, and nothing else. +//! +//! Everything that can live below this file does. What is left here is only +//! what genuinely needs to see more than one of them at once — which pane the +//! workspace is showing, and what a chrome action means. + +use std::{path::PathBuf, rc::Rc, time::Duration}; + +use futures::{StreamExt as _, channel::mpsc}; +use gpui::{FocusHandle, Focusable, Task}; +use ui::prelude::*; +use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; +use zeddy_plugin::PaneKey; +use zeddy_plugin_host::{Catalog, PaneSource, Paths}; +use zeddy_vt::Size; + +use crate::{ + chrome::{self, Action, Entry}, + fonts::Fonts, + keys, + mode::Mode, + palette, + session::Session, + terminal::{Appearance, Fit, TerminalElement}, +}; + +/// How long to wait for the private backend before saying it did not come up. +const BACKEND_TIMEOUT: Duration = Duration::from_secs(10); + +/// What the workspace is showing. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Showing { + Session(usize), + Plugin(PaneKey), + /// Before the first session exists, or after the last one is closed. + Empty, +} + +pub struct Zeddy { + client: Client, + workspace: Option, + sessions: Vec, + showing: Showing, + mode: Mode, + catalog: Catalog, + fit: Fit, + focus: FocusHandle, + /// The last thing that went wrong, shown in place of the workspace. One + /// slot, not a log: what the user needs is the reason the thing they just + /// tried did not happen. + problem: Option, + _wakeups: Task<()>, + wakeup_tx: mpsc::UnboundedSender<()>, +} + +impl Zeddy { + pub fn new(cwd: PathBuf, cx: &mut Context) -> Self { + let namespace = Namespace::private(); + let client = match Sidecar::beside_current_exe() { + Ok(sidecar) => Client::new(sidecar, namespace), + Err(err) => { + // Without a backend there is nothing to show, but the window + // still opens: a window that says why is more useful than one + // that never appears. + return Self::broken(err.to_string(), cx); + } + }; + + let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); + let mut this = Self { + client, + workspace: None, + sessions: Vec::new(), + showing: Showing::Empty, + mode: Mode::default(), + catalog: Catalog::default(), + fit: Fit::default(), + focus: cx.focus_handle(), + problem: None, + _wakeups: Self::watch(wakeup_rx, cx), + wakeup_tx, + }; + + this.catalog = zeddy_plugin_host::load_all(&plugin_paths(), cx); + this.connect(cwd, cx); + this + } + + /// A window with no backend behind it. Everything is empty and the problem + /// is on screen. + fn broken(problem: String, cx: &mut Context) -> Self { + let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); + Self { + client: Client::new( + Sidecar::at(PathBuf::from("/nonexistent")).unwrap_or_else(|_| unreachable!()), + Namespace::private(), + ), + workspace: None, + sessions: Vec::new(), + showing: Showing::Empty, + mode: Mode::default(), + catalog: Catalog::default(), + fit: Fit::default(), + focus: cx.focus_handle(), + problem: Some(problem), + _wakeups: Self::watch(wakeup_rx, cx), + wakeup_tx, + } + } + + /// Redraw whenever any session's reader says something changed. + /// + /// Every wakeup already waiting is drained before the redraw, so a burst of + /// frames costs one paint rather than one paint each. + fn watch(mut wakeups: mpsc::UnboundedReceiver<()>, cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + while wakeups.next().await.is_some() { + while wakeups.try_recv().is_ok() {} + if this.update(cx, |_, cx| cx.notify()).is_err() { + return; + } + } + }) + } + + /// Bring the private backend up and adopt whatever is already running in + /// this directory. + fn connect(&mut self, cwd: PathBuf, cx: &mut Context) { + if let Err(err) = self.client.connect(BACKEND_TIMEOUT) { + self.problem = Some(err.to_string()); + return; + } + + let label = cwd.file_name().map(|name| name.to_string_lossy().into_owned()); + match self.client.open_workspace(&cwd, label.as_deref()) { + Ok(workspace) => { + self.workspace = Some(workspace); + self.refresh(cx); + } + Err(err) => self.problem = Some(err.to_string()), + } + } + + /// Attach to every session the backend is running that zeddy is not showing + /// yet. + /// + /// Adopting rather than creating is the point of a durable backend: a + /// session that outlived the last launch is picked up here, not restarted. + fn refresh(&mut self, cx: &mut Context) { + let known = self.client.sessions(self.workspace.as_ref()); + let listed = match known { + Ok(listed) => listed, + Err(err) => { + self.problem = Some(err.to_string()); + return; + } + }; + + for info in listed { + if self.sessions.iter().any(|session| session.id() == &info.id) { + continue; + } + match Session::attach(&self.client, info, self.grid(), self.wakeup_tx.clone()) { + Ok(session) => self.sessions.push(session), + Err(err) => self.problem = Some(err.to_string()), + } + } + + if matches!(self.showing, Showing::Empty) && !self.sessions.is_empty() { + self.showing = Showing::Session(0); + } + cx.notify(); + } + + /// The grid the last paint found room for, or a sane default before the + /// first one. + fn grid(&self) -> Size { + self.fit.get().unwrap_or_default() + } + + /// Tell the shown session how many cells the last paint found room for. + /// + /// Only the shown one: a background session has no bounds of its own, and + /// resizing it to the visible pane's grid would reflow a screen nobody is + /// looking at. It is resized when it is next shown. + fn fit_shown(&mut self) { + let Some(size) = self.fit.get() else { + return; + }; + let Showing::Session(index) = self.showing else { + return; + }; + if let Some(session) = self.sessions.get_mut(index) + && let Err(err) = session.resize(size) + { + self.problem = Some(err.to_string()); + } + } + + fn act(&mut self, action: Action, cx: &mut Context) { + match action { + Action::ToggleMode => self.mode = self.mode.toggled(), + Action::Select(index) => self.showing = Showing::Session(index), + Action::New => self.start_session(cx), + Action::Close(index) => self.close_session(index, cx), + } + cx.notify(); + } + + fn start_session(&mut self, cx: &mut Context) { + let Some(workspace) = self.workspace.clone() else { + return; + }; + match self.client.start_session(&workspace, None) { + Ok(info) => { + match Session::attach(&self.client, info, self.grid(), self.wakeup_tx.clone()) { + Ok(session) => { + self.sessions.push(session); + self.showing = Showing::Session(self.sessions.len() - 1); + self.problem = None; + } + Err(err) => self.problem = Some(err.to_string()), + } + } + Err(err) => self.problem = Some(err.to_string()), + } + cx.notify(); + } + + fn close_session(&mut self, index: usize, cx: &mut Context) { + if index >= self.sessions.len() { + return; + } + let mut session = self.sessions.remove(index); + if let Err(err) = self.client.close_session(session.id()) { + self.problem = Some(err.to_string()); + } + session.release(); + + // Selection follows the list rather than the index: closing the tab you + // are on should land you on its neighbour, not on nothing. + self.showing = match self.showing.clone() { + Showing::Session(_) if self.sessions.is_empty() => Showing::Empty, + Showing::Session(selected) if selected > index => Showing::Session(selected - 1), + Showing::Session(selected) if selected == index => { + Showing::Session(index.min(self.sessions.len() - 1)) + } + other => other, + }; + cx.notify(); + } + + /// The chrome's view of the sessions, plus the plugin panes that share the + /// same list. + fn entries(&self) -> Vec { + let selected_session = match self.showing { + Showing::Session(index) => Some(index), + _ => None, + }; + + let mut entries: Vec = self + .sessions + .iter() + .enumerate() + .map(|(index, session)| Entry { + title: session.title(), + agent: session.info.agent.clone(), + ended: session.ended().is_some(), + selected: selected_session == Some(index), + }) + .collect(); + + // Plugin panes sit after the sessions, in the catalog's stable order, + // so a plugin cannot change where a session's tab is. + for pane in self.catalog.panes() { + entries.push(Entry { + title: pane.title.clone(), + agent: None, + ended: false, + selected: self.showing == Showing::Plugin(pane.key.clone()), + }); + } + entries + } + + /// Map a chrome index back onto what it selects. The chrome counts one + /// list; this is where it becomes two. + fn showing_for(&self, index: usize) -> Showing { + if index < self.sessions.len() { + Showing::Session(index) + } else { + self.catalog + .panes() + .get(index - self.sessions.len()) + .map(|pane| Showing::Plugin(pane.key.clone())) + .unwrap_or(Showing::Empty) + } + } + + fn on_key(&mut self, event: &gpui::KeyDownEvent, cx: &mut Context) { + let Showing::Session(index) = self.showing else { + return; + }; + let Some(bytes) = keys::bytes_for(&event.keystroke) else { + return; + }; + if let Some(session) = self.sessions.get_mut(index) + && let Err(err) = session.send(&bytes) + { + self.problem = Some(err.to_string()); + cx.notify(); + } + } + + fn workspace_pane(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + // The grid the previous frame measured reaches the backend here, one + // frame late by construction: nothing knows how many cells fit until + // something has been laid out in the space they have to fit in. + self.fit_shown(); + + if let Some(problem) = self.problem.clone() { + return message(&problem, cx).into_any_element(); + } + + match self.showing.clone() { + Showing::Empty => message("No session. Press + to start one.", cx).into_any_element(), + Showing::Session(index) => match self.sessions.get_mut(index) { + Some(session) => { + terminal(session, self.fit.clone(), self.focus.is_focused(window), cx) + .into_any_element() + } + None => message("That session is gone.", cx).into_any_element(), + }, + Showing::Plugin(key) => self.plugin_pane(&key, window, cx), + } + } + + /// Mount a plugin's pane. + /// + /// A native plugin's view is an ordinary GPUI view dropped straight into + /// this element tree — the same frame path as the terminal beside it. + fn plugin_pane( + &mut self, + key: &PaneKey, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(plugin) = self.catalog.get_mut(&key.plugin) else { + return message("That plugin is no longer loaded.", cx).into_any_element(); + }; + match plugin.pane(key) { + Some(PaneSource::Native(plugin)) => plugin.view(key, window, cx).into_any_element(), + Some(PaneSource::Web(entry)) => { + // The webview host is the one piece of the web tier that is not + // written yet; until it is, the pane says so rather than + // pretending to be empty. + message(&format!("Web plugin panes are not hosted yet ({}).", entry.display()), cx) + .into_any_element() + } + None => message("That pane is no longer contributed.", cx).into_any_element(), + } + } +} + +impl Focusable for Zeddy { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for Zeddy { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entries = self.entries(); + // Copied out rather than borrowed: `cx.theme()` borrows `cx`, and + // building the workspace pane below needs it back. + let (background, text, workspace_background) = { + let colors = cx.theme().colors(); + (colors.background, colors.text, colors.editor_background) + }; + + let on_action = cx.listener(|this, action: &Action, _, cx| { + let action = *action; + if let Action::Select(index) = action { + this.showing = this.showing_for(index); + cx.notify(); + } else { + this.act(action, cx); + } + }); + let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); + + // `h_full` is not redundant with `flex_1`. In sidebar mode this sits in + // a row, where `flex_1` decides the *width* and the height would + // otherwise be the content's — which is a terminal that sizes itself to + // its parent, so the pair resolves to nothing at all. + let workspace = v_flex() + .flex_1() + .h_full() + .overflow_hidden() + .bg(workspace_background) + .child(self.workspace_pane(window, cx)); + + let body = match self.mode { + Mode::Sidebar => h_flex() + .size_full() + .child(chrome::sidebar::render(&entries, emit.clone(), cx)) + .child(workspace), + Mode::Tabs => v_flex() + .size_full() + .child(chrome::tabs::render(&entries, emit, cx)) + .child(workspace), + }; + + div() + .track_focus(&self.focus) + .key_context("Zeddy") + .size_full() + .bg(background) + .text_color(text) + .on_key_down(cx.listener(|this, event, _, cx| this.on_key(event, cx))) + .child(body) + } +} + +fn terminal(session: &Session, fit: Fit, focused: bool, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let screen = session.screen(); + let colors = screen + .rows + .iter() + .map(|row| row.iter().map(|cell| palette::cell_colors(cell, theme)).collect()) + .collect(); + + let (font, font_size, line_height) = Fonts::default().terminal(); + let appearance = Appearance { + font, + font_size, + line_height, + background: theme.colors().terminal_background, + cursor: theme.colors().terminal_foreground, + }; + + v_flex().size_full().p_2().child(TerminalElement::new(screen, colors, appearance, focused, fit)) +} + +fn message(text: &str, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Label::new(text.to_owned()).color(Color::Muted)) + .bg(cx.theme().colors().editor_background) +} + +fn plugin_paths() -> Paths { + let root = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share") + }); + Paths::under(root.join("zeddy")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn closing_the_selected_session_lands_on_its_neighbour() { + // The selection rule is arithmetic on indices, so it is tested as such + // rather than through a live backend. + let after = |selected: usize, closed: usize, remaining: usize| -> Showing { + match Showing::Session(selected) { + Showing::Session(_) if remaining == 0 => Showing::Empty, + Showing::Session(s) if s > closed => Showing::Session(s - 1), + Showing::Session(s) if s == closed => Showing::Session(closed.min(remaining - 1)), + other => other, + } + }; + + assert_eq!(after(1, 1, 2), Showing::Session(1), "the next one takes the index"); + assert_eq!(after(2, 2, 2), Showing::Session(1), "closing the last selects the new last"); + assert_eq!(after(2, 0, 2), Showing::Session(1), "closing before shifts the selection down"); + assert_eq!(after(0, 1, 2), Showing::Session(0), "closing after leaves it alone"); + assert_eq!(after(0, 0, 0), Showing::Empty, "closing the only one shows nothing"); + } +} diff --git a/crates/zeddy/src/assets.rs b/crates/zeddy/src/assets.rs new file mode 100644 index 00000000..94e55618 --- /dev/null +++ b/crates/zeddy/src/assets.rs @@ -0,0 +1,63 @@ +//! zeddy's assets: four icons, compiled in. +//! +//! Zed's `ui` components ask an [`AssetSource`] for an icon by path. zeddy uses +//! four of them, so they are embedded with `include_str!` rather than read from +//! a directory beside the binary — a GUI that cannot find its own icons at +//! runtime is a class of bug worth not having. +//! +//! Asking for anything else answers `None` rather than failing. A component +//! zeddy does not draw is not a missing asset, and an icon that silently does +//! not appear is a better failure than a window that does not open. + +use std::borrow::Cow; + +use gpui::{AssetSource, SharedString}; + +pub struct Assets; + +/// Icons zeddy draws, by the path `IconName::path` derives. +const ICONS: &[(&str, &str)] = &[ + ("icons/plus.svg", include_str!("../assets/icons/plus.svg")), + ("icons/close.svg", include_str!("../assets/icons/close.svg")), + ("icons/tab.svg", include_str!("../assets/icons/tab.svg")), + ("icons/menu.svg", include_str!("../assets/icons/menu.svg")), +]; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> gpui::Result>> { + Ok(ICONS + .iter() + .find(|(name, _)| *name == path) + .map(|(_, svg)| Cow::Borrowed(svg.as_bytes()))) + } + + fn list(&self, path: &str) -> gpui::Result> { + Ok(ICONS + .iter() + .filter(|(name, _)| name.starts_with(path)) + .map(|(name, _)| SharedString::from(*name)) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ui::IconName; + + #[test] + fn every_icon_zeddy_draws_is_embedded() { + for icon in [IconName::Plus, IconName::Close, IconName::Tab, IconName::Menu] { + let path = icon.path(); + assert!( + Assets.load(&path).expect("load").is_some(), + "{path} is drawn by zeddy but not embedded" + ); + } + } + + #[test] + fn an_icon_zeddy_does_not_draw_is_absent_rather_than_an_error() { + assert!(Assets.load("icons/nonexistent.svg").expect("load").is_none()); + } +} diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs new file mode 100644 index 00000000..a922620f --- /dev/null +++ b/crates/zeddy/src/chrome.rs @@ -0,0 +1,54 @@ +//! The two chromes, and the one thing they have in common. +//! +//! A chrome is a list of sessions with one of them selected. Sidebar mode draws +//! that list down the left; tabs mode draws it across the top. Neither knows +//! anything else about the app, which is what keeps the two implementations to +//! a screenful each: they take [`Entry`] values and emit indices. + +pub mod sidebar; +pub mod tabs; + +use std::rc::Rc; + +use ui::prelude::*; + +/// One row in the sidebar, or one tab in the strip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub title: String, + /// The agent herdr believes is running, when it knows one. In sidebar mode + /// this is a second line; in tabs mode there is no room and it is dropped. + pub agent: Option, + /// A session whose reader has stopped is still listed — closing it is the + /// user's decision, not something that happens to them. + pub ended: bool, + pub selected: bool, +} + +/// What the user did to the chrome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Select(usize), + Close(usize), + New, + ToggleMode, +} + +/// How a chrome reports what the user did. +/// +/// `Rc` because both chromes hand the same callback to every row they draw, +/// and a `cx.listener` closure is not `Clone`. +pub type Emit = Rc; + +/// The dot that carries a session's state, in the one place both chromes agree +/// on what it means. +pub fn status_dot(entry: &Entry, cx: &App) -> impl IntoElement { + let color = if entry.ended { + cx.theme().status().error + } else if entry.agent.is_some() { + cx.theme().status().success + } else { + cx.theme().colors().text_muted + }; + div().size(px(6.)).rounded_full().bg(color).flex_none() +} diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs new file mode 100644 index 00000000..15c75229 --- /dev/null +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -0,0 +1,95 @@ +//! Sidebar mode: the session list down the left. +//! +//! The mode for many long-lived sessions. There is room here for the things a +//! tab cannot hold — the agent's name under the title, and a close button that +//! is not fighting the title for space — so this chrome shows them. + +use ui::{Tooltip, prelude::*}; + +use super::Emit; + +use super::{Action, Entry, status_dot}; + +/// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a +/// preference to persist, a drag handle to hit-test, and a minimum to enforce, +/// and none of that is what makes this mode useful. +pub const WIDTH: Pixels = px(220.); + +pub fn render(entries: &[Entry], on: Emit, cx: &App) -> impl IntoElement { + let colors = cx.theme().colors(); + + v_flex() + .w(WIDTH) + .flex_none() + .h_full() + .bg(colors.panel_background) + .border_r_1() + .border_color(colors.border) + .child(header(on.clone())) + .child(v_flex().id("sessions").flex_1().overflow_y_scroll().p_1().gap_px().children( + entries.iter().enumerate().map(|(index, entry)| row(index, entry, on.clone(), cx)), + )) +} + +fn header(on: Emit) -> impl IntoElement { + let toggle = on.clone(); + h_flex() + .h(px(36.)) + .px_2() + .gap_1() + .justify_between() + .child(Label::new("Sessions").size(LabelSize::Small).color(Color::Muted)) + .child( + h_flex() + .gap_px() + .child( + IconButton::new("new-session", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("New session")) + .on_click(move |_, window, cx| on(Action::New, window, cx)), + ) + .child( + IconButton::new("toggle-mode", IconName::Tab) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Switch to tabs")) + .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), + ), + ) +} + +fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { + let colors = cx.theme().colors(); + let close = on.clone(); + + h_flex() + .id(("session", index)) + .group("session") + .h(px(38.)) + .px_2() + .gap_2() + .rounded_sm() + .when(entry.selected, |row| row.bg(colors.element_selected)) + .when(!entry.selected, |row| row.hover(|row| row.bg(colors.element_hover))) + .on_click(move |_, window, cx| on(Action::Select(index), window, cx)) + .child(status_dot(entry, cx)) + .child( + v_flex() + .flex_1() + .overflow_hidden() + .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()) + .when_some(entry.agent.clone(), |column, agent| { + column.child( + Label::new(agent).size(LabelSize::XSmall).color(Color::Muted).truncate(), + ) + }), + ) + .child( + // Revealed on hover so a list of ten sessions is ten titles rather + // than ten titles and ten buttons. + div().visible_on_hover("session").child( + IconButton::new(("close", index), IconName::Close) + .icon_size(IconSize::XSmall) + .on_click(move |_, window, cx| close(Action::Close(index), window, cx)), + ), + ) +} diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs new file mode 100644 index 00000000..7771931e --- /dev/null +++ b/crates/zeddy/src/chrome/tabs.rs @@ -0,0 +1,83 @@ +//! Tabs mode: the session list across the top. +//! +//! The mode for a handful of sessions you are switching between quickly. A tab +//! has no second line, so the agent's name is dropped here rather than +//! squeezed in — the dot still carries the state, and the title carries the +//! identity. + +use ui::{Tooltip, prelude::*}; + +use super::Emit; + +use super::{Action, Entry, status_dot}; + +pub const HEIGHT: Pixels = px(32.); + +pub fn render(entries: &[Entry], on: Emit, cx: &App) -> impl IntoElement { + let colors = cx.theme().colors(); + let new = on.clone(); + let toggle = on.clone(); + + h_flex() + .h(HEIGHT) + .flex_none() + .w_full() + .bg(colors.tab_bar_background) + .border_b_1() + .border_color(colors.border) + .child(h_flex().id("tabs").flex_1().overflow_x_scroll().children( + entries.iter().enumerate().map(|(index, entry)| tab(index, entry, on.clone(), cx)), + )) + .child( + h_flex() + .px_1() + .gap_px() + .flex_none() + .child( + IconButton::new("new-session", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("New session")) + .on_click(move |_, window, cx| new(Action::New, window, cx)), + ) + .child( + IconButton::new("toggle-mode", IconName::Menu) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Switch to sidebar")) + .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), + ), + ) +} + +fn tab(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { + let colors = cx.theme().colors(); + let close = on.clone(); + + h_flex() + .id(("tab", index)) + .group("tab") + .h_full() + .px_2() + .gap_1p5() + .max_w(px(200.)) + .border_r_1() + .border_color(colors.border) + .when(entry.selected, |tab| tab.bg(colors.tab_active_background)) + .when(!entry.selected, |tab| { + tab.bg(colors.tab_inactive_background).hover(|tab| tab.bg(colors.element_hover)) + }) + .on_click(move |_, window, cx| on(Action::Select(index), window, cx)) + .child(status_dot(entry, cx)) + .child( + Label::new(entry.title.clone()) + .size(LabelSize::Small) + .color(if entry.selected { Color::Default } else { Color::Muted }) + .truncate(), + ) + .child( + div().visible_on_hover("tab").child( + IconButton::new(("close", index), IconName::Close) + .icon_size(IconSize::XSmall) + .on_click(move |_, window, cx| close(Action::Close(index), window, cx)), + ), + ) +} diff --git a/crates/zeddy/src/fonts.rs b/crates/zeddy/src/fonts.rs new file mode 100644 index 00000000..ff295166 --- /dev/null +++ b/crates/zeddy/src/fonts.rs @@ -0,0 +1,96 @@ +//! zeddy's fonts. +//! +//! Zed's `ui` components read their font and size through a +//! [`ThemeSettingsProvider`], which the `theme_settings` crate normally fills +//! in from the user's settings file. zeddy has no settings file, so it answers +//! the five questions itself — and this is then also the one place the terminal +//! font is chosen, rather than a constant in the renderer. + +use gpui::{App, Font, Pixels, px}; +use theme::{ThemeSettingsProvider, UiDensity}; + +/// The families zeddy asks for, and the sizes it draws them at. +pub struct Fonts { + ui: Font, + buffer: Font, +} + +/// The UI face. GPUI resolves this to the platform's own system font. +const UI_FAMILY: &str = ".SystemUIFont"; + +/// The monospace face the terminal is drawn in: the one every one of these +/// platforms ships, so it is there without zeddy bundling a font file. +const MONOSPACE_FAMILY: &str = if cfg!(target_os = "macos") { + "Menlo" +} else if cfg!(target_os = "windows") { + "Consolas" +} else { + "DejaVu Sans Mono" +}; + +impl Default for Fonts { + fn default() -> Self { + Self { ui: gpui::font(UI_FAMILY), buffer: gpui::font(MONOSPACE_FAMILY) } + } +} + +impl Fonts { + /// The terminal's font and the line height to draw it at. + /// + /// The ratio is the one every terminal uses and nobody writes down: a line + /// box about 1.4× the point size, which leaves box-drawing characters + /// touching and leaves text legible. + pub fn terminal(&self) -> (Font, Pixels, Pixels) { + let size = px(13.); + (self.buffer.clone(), size, (size * 1.4).round()) + } +} + +/// Whether the platform can actually rasterise text. +/// +/// GPUI answers `all_font_names` with its own hardcoded fallback list even when +/// the platform text system is the one that draws nothing, so "is the list +/// empty" is not the question. The question is whether a family the operating +/// system really ships is in it. +pub fn text_renders(cx: &App) -> bool { + cx.text_system().all_font_names().iter().any(|name| name == MONOSPACE_FAMILY) +} + +impl ThemeSettingsProvider for Fonts { + fn ui_font<'a>(&'a self, _: &'a App) -> &'a Font { + &self.ui + } + + fn buffer_font<'a>(&'a self, _: &'a App) -> &'a Font { + &self.buffer + } + + fn ui_font_size(&self, _: &App) -> Pixels { + px(14.) + } + + fn buffer_font_size(&self, _: &App) -> Pixels { + self.terminal().1 + } + + fn ui_density(&self, _: &App) -> UiDensity { + UiDensity::Default + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_terminal_line_box_leaves_room_for_descenders() { + let (_, size, line_height) = Fonts::default().terminal(); + assert!(line_height > size, "glyphs would clip"); + assert!(line_height < size * 2., "the grid would look double-spaced"); + } + + #[test] + fn zeddy_names_a_family_on_every_platform() { + assert!(!MONOSPACE_FAMILY.is_empty() && !UI_FAMILY.is_empty()); + } +} diff --git a/crates/zeddy/src/keys.rs b/crates/zeddy/src/keys.rs new file mode 100644 index 00000000..d94225b4 --- /dev/null +++ b/crates/zeddy/src/keys.rs @@ -0,0 +1,147 @@ +//! Turning a keystroke into the bytes a terminal expects. +//! +//! GPUI hands us a [`Keystroke`] — a key name and a set of modifiers. A PTY +//! wants bytes. This is the whole translation, kept in one file with tests +//! because it is the part of a terminal that is quietly wrong for years if +//! nobody checks it. +//! +//! Only the sequences an agent session actually needs are here: text, the +//! control range, the arrows and their bracketed forms, and the editing keys. +//! Mouse reporting, the kitty keyboard protocol, and application-cursor mode +//! are deliberately absent — none of them is reachable through herdr's frame +//! stream, which sends a re-render rather than the program's own output. + +use gpui::Keystroke; + +/// The bytes to send for a keystroke, or `None` for one that means nothing to +/// a terminal (a bare modifier, an unhandled function key). +pub fn bytes_for(keystroke: &Keystroke) -> Option> { + let modifiers = &keystroke.modifiers; + + let named = match keystroke.key.as_str() { + "enter" => Some("\r"), + "tab" if modifiers.shift => Some("\x1b[Z"), + "tab" => Some("\t"), + "backspace" => Some("\x7f"), + "escape" => Some("\x1b"), + "space" => Some(" "), + "up" => Some("\x1b[A"), + "down" => Some("\x1b[B"), + "right" => Some("\x1b[C"), + "left" => Some("\x1b[D"), + "home" => Some("\x1b[H"), + "end" => Some("\x1b[F"), + "pageup" => Some("\x1b[5~"), + "pagedown" => Some("\x1b[6~"), + "delete" => Some("\x1b[3~"), + "insert" => Some("\x1b[2~"), + _ => None, + }; + + if let Some(named) = named { + return Some(with_alt(named.as_bytes(), modifiers.alt)); + } + + // Control folds a letter into the C0 range: ^A is 1, ^Z is 26. The handful + // of punctuation controls follow the same table. + if modifiers.control { + let byte = match keystroke.key.as_str() { + key if key.len() == 1 => { + let c = key.chars().next().expect("one char"); + match c { + 'a'..='z' => Some(c as u8 - b'a' + 1), + '@' | ' ' => Some(0), + '[' => Some(27), + '\\' => Some(28), + ']' => Some(29), + '^' => Some(30), + '_' | '?' => Some(31), + _ => None, + } + } + _ => None, + }; + return byte.map(|byte| with_alt(&[byte], modifiers.alt)); + } + + // Anything else is text, and GPUI already worked out what text it is — + // including the shifted and dead-key forms this code should not re-derive. + let text = keystroke.key_char.as_deref().filter(|text| !text.is_empty())?; + Some(with_alt(text.as_bytes(), modifiers.alt)) +} + +/// Alt is a leading escape. That is what a terminal means by "meta". +fn with_alt(bytes: &[u8], alt: bool) -> Vec { + if alt { + let mut out = Vec::with_capacity(bytes.len() + 1); + out.push(0x1b); + out.extend_from_slice(bytes); + out + } else { + bytes.to_vec() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(spec: &str) -> Keystroke { + Keystroke::parse(spec).expect("a parseable keystroke") + } + + fn typed(spec: &str, text: &str) -> Keystroke { + let mut keystroke = key(spec); + keystroke.key_char = Some(text.to_owned()); + keystroke + } + + #[test] + fn plain_text_goes_through_as_itself() { + assert_eq!(bytes_for(&typed("a", "a")), Some(b"a".to_vec())); + assert_eq!(bytes_for(&typed("shift-a", "A")), Some(b"A".to_vec())); + } + + #[test] + fn enter_is_a_carriage_return_and_not_a_newline() { + // A PTY in canonical mode reads CR as "submit"; LF would insert a line. + assert_eq!(bytes_for(&key("enter")), Some(b"\r".to_vec())); + } + + #[test] + fn backspace_is_del_which_is_what_readline_expects() { + assert_eq!(bytes_for(&key("backspace")), Some(vec![0x7f])); + } + + #[test] + fn control_letters_fold_into_the_c0_range() { + assert_eq!(bytes_for(&key("ctrl-a")), Some(vec![1])); + assert_eq!(bytes_for(&key("ctrl-c")), Some(vec![3])); + assert_eq!(bytes_for(&key("ctrl-z")), Some(vec![26])); + } + + #[test] + fn the_arrows_are_csi_sequences() { + assert_eq!(bytes_for(&key("up")), Some(b"\x1b[A".to_vec())); + assert_eq!(bytes_for(&key("left")), Some(b"\x1b[D".to_vec())); + } + + #[test] + fn shift_tab_is_a_back_tab_and_not_a_tab() { + assert_eq!(bytes_for(&key("tab")), Some(b"\t".to_vec())); + assert_eq!(bytes_for(&key("shift-tab")), Some(b"\x1b[Z".to_vec())); + } + + #[test] + fn alt_prefixes_an_escape_whatever_the_key_was() { + assert_eq!(bytes_for(&typed("alt-b", "b")), Some(b"\x1bb".to_vec())); + assert_eq!(bytes_for(&key("alt-up")), Some(b"\x1b\x1b[A".to_vec())); + assert_eq!(bytes_for(&key("ctrl-alt-a")), Some(vec![0x1b, 1])); + } + + #[test] + fn a_keystroke_with_no_text_and_no_name_sends_nothing() { + // An unhandled function key must send nothing rather than send garbage. + assert_eq!(bytes_for(&key("f13")), None); + } +} diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs new file mode 100644 index 00000000..5505bbea --- /dev/null +++ b/crates/zeddy/src/main.rs @@ -0,0 +1,66 @@ +//! zeddy — a simple agent multiplexer. + +use std::path::PathBuf; + +use gpui::{App, AppContext as _, Bounds, Focusable as _, WindowBounds, WindowOptions, px, size}; +use gpui_platform::application; + +mod app; +mod assets; +mod chrome; +mod fonts; +mod keys; +mod mode; +mod palette; +mod session; +mod terminal; + +fn main() { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + application().with_assets(assets::Assets).run(move |cx: &mut App| { + // `JustBase` loads no theme JSON, which means no asset source and no + // bundled themes. zeddy has no theme picker, so the built-in dark theme + // is the whole theming story until it does. + theme::init(theme::LoadThemes::JustBase, cx); + // Zed's components read their font through this, and zeddy has no + // settings file for the `theme_settings` crate to read one from. + theme::set_theme_settings_provider(Box::new(fonts::Fonts::default()), cx); + + // A build whose platform layer cannot rasterise glyphs paints every + // quad and icon correctly and shows not one character. Saying so is + // better than opening that window. + if !fonts::text_renders(cx) { + eprintln!( + "zeddy cannot render text: this build's GPUI platform layer has no font \ + backend. Check that `gpui_platform` is built with the `font-kit` feature." + ); + cx.quit(); + return; + } + + let bounds = Bounds::centered(None, size(px(1100.), px(720.)), cx); + let window = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("zeddy".into()), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| { + let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), cx)); + window.focus(&view.read(cx).focus_handle(cx), cx); + view + }, + ); + + if let Err(err) = window { + eprintln!("zeddy could not open a window: {err}"); + cx.quit(); + return; + } + cx.activate(true); + }); +} diff --git a/crates/zeddy/src/mode.rs b/crates/zeddy/src/mode.rs new file mode 100644 index 00000000..f369f247 --- /dev/null +++ b/crates/zeddy/src/mode.rs @@ -0,0 +1,42 @@ +//! The two ways zeddy arranges the same sessions. +//! +//! Both modes show one session at a time and switch between the same list. The +//! difference is only where the list lives, so the mode is one enum and not two +//! layouts: nothing below the chrome knows which one is showing, and toggling +//! never touches a session. + +/// Where the session list is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + /// A vertical list down the left. Wide enough for a directory, an agent + /// name, and a status — the mode for many long-lived sessions. + #[default] + Sidebar, + /// A horizontal strip across the top. Familiar, and denser per session — + /// the mode for a handful of things you are switching between quickly. + Tabs, +} + +impl Mode { + pub fn toggled(self) -> Self { + match self { + Self::Sidebar => Self::Tabs, + Self::Tabs => Self::Sidebar, + } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toggling_twice_is_the_identity() { + assert_eq!(Mode::Sidebar.toggled().toggled(), Mode::Sidebar); + assert_eq!(Mode::Tabs.toggled().toggled(), Mode::Tabs); + } + + #[test] + fn the_two_modes_are_the_only_two() { + assert_eq!(Mode::default(), Mode::Sidebar); + } +} diff --git a/crates/zeddy/src/palette.rs b/crates/zeddy/src/palette.rs new file mode 100644 index 00000000..b38707b7 --- /dev/null +++ b/crates/zeddy/src/palette.rs @@ -0,0 +1,199 @@ +//! Turning a cell's colour into a colour the window can paint. +//! +//! [`zeddy_vt::Color`] deliberately does not resolve anything: it says +//! "indexed 4" or "default", and *what those are* belongs to the theme. This is +//! where that is decided, and it is the only place — so switching themes is a +//! re-render rather than a re-parse. + +use gpui::{Hsla, Rgba}; +use theme::Theme; +use zeddy_vt::{Cell, Color, Style}; + +/// Whether a colour is standing in for the foreground or the background. +/// +/// [`Color::Default`] means different things in the two positions, and this is +/// how the caller says which one it is asking about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Slot { + Foreground, + Background, +} + +/// Resolve one colour against the active theme. +pub fn resolve(color: Color, slot: Slot, theme: &Theme) -> Hsla { + match color { + Color::Default => match slot { + Slot::Foreground => theme.colors().terminal_foreground, + Slot::Background => theme.colors().terminal_background, + }, + Color::Rgb(r, g, b) => { + Rgba { r: f32::from(r) / 255., g: f32::from(g) / 255., b: f32::from(b) / 255., a: 1. } + .into() + } + Color::Indexed(index) => indexed(index, slot, theme), + } +} + +/// The foreground and background a cell is actually painted with, after +/// `inverse` and `dim` have been applied. +/// +/// Applied here rather than in the VT crate because both depend on what the +/// theme's defaults are, and the VT crate does not have a theme. +pub fn cell_colors(cell: &Cell, theme: &Theme) -> (Hsla, Hsla) { + let Style { inverse, dim, .. } = cell.style; + let (fg_color, bg_color) = if inverse { (cell.bg, cell.fg) } else { (cell.fg, cell.bg) }; + let (fg_slot, bg_slot) = if inverse { + (Slot::Background, Slot::Foreground) + } else { + (Slot::Foreground, Slot::Background) + }; + + let mut fg = resolve(fg_color, fg_slot, theme); + if dim { + fg.a *= 0.7; + } + (fg, resolve(bg_color, bg_slot, theme)) +} + +/// The sixteen ANSI slots, plus the 256-colour cube and greyscale ramp. +/// +/// Zed's theme names the sixteen; 16..=255 are the xterm cube, which is defined +/// arithmetically and is not a theme's to override. +fn indexed(index: u8, slot: Slot, theme: &Theme) -> Hsla { + let colors = theme.colors(); + match index { + 0 => colors.terminal_ansi_black, + 1 => colors.terminal_ansi_red, + 2 => colors.terminal_ansi_green, + 3 => colors.terminal_ansi_yellow, + 4 => colors.terminal_ansi_blue, + 5 => colors.terminal_ansi_magenta, + 6 => colors.terminal_ansi_cyan, + 7 => colors.terminal_ansi_white, + 8 => colors.terminal_ansi_bright_black, + 9 => colors.terminal_ansi_bright_red, + 10 => colors.terminal_ansi_bright_green, + 11 => colors.terminal_ansi_bright_yellow, + 12 => colors.terminal_ansi_bright_blue, + 13 => colors.terminal_ansi_bright_magenta, + 14 => colors.terminal_ansi_bright_cyan, + 15 => colors.terminal_ansi_bright_white, + 16..=231 => { + // The 6×6×6 cube. The steps are xterm's, not evenly spaced: the + // first is 0 and the rest are 95 + 40n. + let value = index - 16; + let step = |n: u8| match n { + 0 => 0u8, + n => 95 + 40 * (n - 1), + }; + let (r, g, b) = (step(value / 36), step((value % 36) / 6), step(value % 6)); + resolve(Color::Rgb(r, g, b), slot, theme) + } + 232..=255 => { + let level = 8 + 10 * (index - 232); + resolve(Color::Rgb(level, level, level), slot, theme) + } + } +} + +#[cfg(test)] +mod tests { + //! Run against the theme the app actually boots with, rather than a + //! hand-built one: what these assert is that the mapping agrees with Zed's + //! palette, and a fixture theme could not tell us that. + + use super::*; + use gpui::TestAppContext; + use theme::ActiveTheme as _; + use zeddy_vt::Style; + + fn cell(fg: Color, bg: Color, style: Style) -> Cell { + Cell { ch: 'x', fg, bg, style } + } + + fn with_theme(cx: &mut TestAppContext, f: impl FnOnce(&Theme) -> R) -> R { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + f(cx.theme()) + }) + } + + #[gpui::test] + fn default_means_something_different_in_each_slot(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + assert_ne!( + resolve(Color::Default, Slot::Foreground, theme), + resolve(Color::Default, Slot::Background, theme) + ); + }); + } + + #[gpui::test] + fn true_colour_is_passed_through_untouched(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + let painted = resolve(Color::Rgb(255, 0, 0), Slot::Foreground, theme); + assert_eq!(painted, Hsla::from(Rgba { r: 1., g: 0., b: 0., a: 1. })); + }); + } + + #[gpui::test] + fn the_sixteen_ansi_slots_come_from_the_theme(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + assert_eq!( + resolve(Color::Indexed(1), Slot::Foreground, theme), + theme.colors().terminal_ansi_red + ); + assert_eq!( + resolve(Color::Indexed(9), Slot::Foreground, theme), + theme.colors().terminal_ansi_bright_red + ); + }); + } + + #[gpui::test] + fn the_cube_follows_xterms_uneven_steps(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + // 16 is the cube's black corner, 231 its white one. + assert_eq!( + resolve(Color::Indexed(16), Slot::Foreground, theme), + resolve(Color::Rgb(0, 0, 0), Slot::Foreground, theme) + ); + assert_eq!( + resolve(Color::Indexed(231), Slot::Foreground, theme), + resolve(Color::Rgb(255, 255, 255), Slot::Foreground, theme) + ); + }); + } + + #[gpui::test] + fn the_greyscale_ramp_is_grey(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + let grey = resolve(Color::Indexed(240), Slot::Foreground, theme); + assert_eq!(grey.s, 0., "a ramp entry with saturation is not grey"); + }); + } + + #[gpui::test] + fn inverse_swaps_the_two_slots_and_not_merely_the_two_colours(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + let plain = cell(Color::Default, Color::Default, Style::default()); + let inverted = + cell(Color::Default, Color::Default, Style { inverse: true, ..Default::default() }); + assert_eq!(cell_colors(&plain, theme), { + let (fg, bg) = cell_colors(&inverted, theme); + (bg, fg) + }); + }); + } + + #[gpui::test] + fn dim_fades_the_foreground_and_leaves_the_background_alone(cx: &mut TestAppContext) { + with_theme(cx, |theme| { + let dimmed = + cell(Color::Indexed(2), Color::Default, Style { dim: true, ..Default::default() }); + let (fg, bg) = cell_colors(&dimmed, theme); + assert!(fg.a < 1.0); + assert_eq!(bg, theme.colors().terminal_background); + }); + } +} diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs new file mode 100644 index 00000000..84b91b03 --- /dev/null +++ b/crates/zeddy/src/session.rs @@ -0,0 +1,182 @@ +//! One live session: an attachment, an emulator, and the thread between them. +//! +//! # Why a thread and not a task +//! +//! [`Frames::next_frame`] blocks until herdr has something to say, which for an idle +//! session is never. Parking a GPUI executor task on that would hold an +//! executor thread hostage per idle session, so each attachment gets a real +//! thread of its own, and the only thing that crosses back to the window is a +//! wakeup. +//! +//! # Why the window never reads a frame +//! +//! The reader thread applies frames to the emulator itself, under a mutex, and +//! then says only "something changed". The window's job is to take a snapshot +//! when it paints. That keeps frame application off the frame path entirely: a +//! session producing a thousand repaints a second costs the window one redraw +//! per vsync, not a thousand. + +use std::sync::{Arc, Mutex}; + +use futures::channel::mpsc; +use zeddy_herdr::{ + Geometry, PaneId, + control::{self, Client}, + stream::Input, +}; +use zeddy_vt::{Screen, Size, Terminal}; + +/// A wakeup from a session's reader thread. Carries nothing: the state is in +/// the emulator, and the message only says to look at it. +pub type Wakeup = (); + +/// The reader half's outcome, once it stops. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ended { + /// herdr closed the stream: the session exited, or something else took it. + Closed, + /// The stream broke. The message is the one to show in the pane. + Failed(String), +} + +/// One attached session. +pub struct Session { + pub info: control::Session, + terminal: Arc>, + ended: Arc>>, + input: Input, + size: Size, +} + +impl Session { + /// Attach to a pane and start reading it. + /// + /// The wakeup sender is cloned per session; the app holds one receiver for + /// all of them and redraws when any session speaks. + pub fn attach( + client: &Client, + info: control::Session, + size: Size, + wakeups: mpsc::UnboundedSender, + ) -> zeddy_herdr::Result { + let attachment = client.attach(&info.id, geometry(size))?; + let (mut frames, input) = attachment.split(); + + let terminal = Arc::new(Mutex::new(Terminal::new(size))); + let ended = Arc::new(Mutex::new(None)); + + std::thread::Builder::new() + .name(format!("zeddy-session-{}", info.id)) + .spawn({ + let terminal = terminal.clone(); + let ended = ended.clone(); + move || { + let outcome = loop { + match frames.next_frame() { + Ok(Some(frame)) => { + // A full repaint after a resize is measured + // against a grid of its own size, so the + // emulator follows the frame rather than the + // window: applying an 80-column repaint to a + // 120-column grid would wrap it wrongly. + let mut terminal = terminal.lock().expect("terminal mutex"); + if frame.full { + terminal.resize(size_of(frame.geometry)); + } + terminal.feed(&frame.bytes); + } + Ok(None) => break Ended::Closed, + Err(err) => break Ended::Failed(err.to_string()), + } + // Sent after the frame is applied, so a redraw woken by + // this always sees it. A closed receiver means the + // window is gone, and so is the reason to keep reading. + if wakeups.unbounded_send(()).is_err() { + return; + } + }; + *ended.lock().expect("ended mutex") = Some(outcome); + let _ = wakeups.unbounded_send(()); + } + }) + .expect("spawn a session reader thread"); + + Ok(Self { info, terminal, ended, input, size }) + } + + pub fn id(&self) -> &PaneId { + &self.info.id + } + + /// The screen as it stands. Cheap enough to call once per paint. + pub fn screen(&self) -> Screen { + self.terminal.lock().expect("terminal mutex").screen() + } + + /// Whether the reader has stopped, and why. + pub fn ended(&self) -> Option { + self.ended.lock().expect("ended mutex").clone() + } + + /// The title to show: whatever the program set, else what herdr called it. + pub fn title(&self) -> String { + self.terminal + .lock() + .expect("terminal mutex") + .screen() + .title + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| self.info.title.clone()) + } + + /// Send typed bytes to the session. + pub fn send(&mut self, bytes: &[u8]) -> zeddy_herdr::Result<()> { + self.input.send(bytes) + } + + /// Tell the session how many cells it now has. + /// + /// A no-op at the same size, because a resize costs a full repaint and the + /// window recomputes its cell count on every layout pass. + pub fn resize(&mut self, size: Size) -> zeddy_herdr::Result<()> { + if size == self.size { + return Ok(()); + } + self.size = size; + self.input.resize(geometry(size)) + } + + /// Detach cleanly, leaving the session running for the next launch. + pub fn release(&mut self) { + let _ = self.input.release(); + } +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("id", &self.info.id) + .field("size", &self.size) + .finish_non_exhaustive() + } +} + +/// The two crates below zeddy each have their own name for a grid, and neither +/// should have to know about the other. These two functions are the seam. +fn geometry(size: Size) -> Geometry { + Geometry::new(size.cols, size.rows) +} + +fn size_of(geometry: Geometry) -> Size { + Size::new(geometry.cols, geometry.rows) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_two_grid_types_round_trip() { + assert_eq!(size_of(geometry(Size::new(120, 40))), Size::new(120, 40)); + } +} diff --git a/crates/zeddy/src/terminal.rs b/crates/zeddy/src/terminal.rs new file mode 100644 index 00000000..76ce12d2 --- /dev/null +++ b/crates/zeddy/src/terminal.rs @@ -0,0 +1,276 @@ +//! Painting a [`Screen`]. +//! +//! This is a custom [`Element`] rather than a tree of styled `div`s. A terminal +//! is a grid of thousands of cells that changes many times a second, and a div +//! per cell would put a Taffy layout node per cell on the frame path. Here the +//! whole screen is one element: one shaped line per row, and the runs inside it +//! carry the colours. +//! +//! # The grid is measured here and used elsewhere +//! +//! How many cells fit is a question only the paint pass can answer — it depends +//! on the font metrics and on the bounds the layout gave us. But the *answer* +//! belongs to the session, which has to tell herdr about it. So the element +//! writes the measured grid into a shared [`Fit`] and the view reads it, which +//! is why a resize takes effect on the frame after the one that noticed it. + +use std::{cell::Cell as StdCell, rc::Rc}; + +use gpui::{ + App, Bounds, Element, ElementId, Font, FontWeight, GlobalElementId, Hsla, InspectorElementId, + IntoElement, LayoutId, Pixels, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, + fill, point, px, size, +}; +use zeddy_vt::{Screen, Size}; + +/// The grid the last paint found room for. +/// +/// Shared between the element that measures it and the view that acts on it. +/// A plain `Cell` because both ends are on the window thread. +/// +/// Read rather than consumed: the view checks it on every frame and the session +/// ignores a size it is already running at, so a steady window costs one +/// comparison per frame and a dragged one costs a resize per frame. +#[derive(Debug, Clone, Default)] +pub struct Fit(Rc>>); + +impl Fit { + pub fn get(&self) -> Option { + self.0.get() + } + + fn set(&self, size: Size) { + self.0.set(Some(size)); + } +} + +/// How a terminal is drawn: the font, and the colours a cell's `Default` means. +#[derive(Debug, Clone)] +pub struct Appearance { + pub font: Font, + pub font_size: Pixels, + pub line_height: Pixels, + pub background: Hsla, + pub cursor: Hsla, +} + +/// One screen, painted. +pub struct TerminalElement { + screen: Screen, + appearance: Appearance, + /// A blurred terminal draws a hollow cursor, the way every native terminal + /// does — it is how you tell at a glance which pane has the keyboard. + focused: bool, + fit: Fit, + /// Resolved by the caller, because only it has the theme. + colors: Vec>, +} + +impl TerminalElement { + pub fn new( + screen: Screen, + colors: Vec>, + appearance: Appearance, + focused: bool, + fit: Fit, + ) -> Self { + Self { screen, appearance, focused, fit, colors } + } +} + +/// What [`Element::prepaint`] worked out and [`Element::paint`] needs. +pub struct Metrics { + cell: gpui::Size, +} + +impl IntoElement for TerminalElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TerminalElement { + type RequestLayoutState = (); + type PrepaintState = Metrics; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + // Full width, and *grown* into the remaining height rather than sized + // at 100% of it: a percentage height against a parent whose own height + // comes from a flex line resolves to zero, and a terminal one cell tall + // is not an obvious-looking bug — it looks like a terminal that will not + // scroll. The parent is a column, so growing is what fills it. + let style = Style { + flex_grow: 1., + size: size(gpui::relative(1.).into(), gpui::Length::Auto), + ..Style::default() + }; + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + _: &mut App, + ) -> Metrics { + // The font is monospace, so one glyph's advance is every glyph's. + let em = window + .text_system() + .shape_line( + SharedString::from("M"), + self.appearance.font_size, + &[Look { + fg: gpui::black(), + bg: None, + bold: false, + italic: false, + underline: false, + } + .run(1, &self.appearance)], + None, + ) + .width + .max(px(1.)); + let cell = size(em, self.appearance.line_height); + + self.fit.set(Size::new( + (bounds.size.width / cell.width).floor() as u16, + (bounds.size.height / cell.height).floor() as u16, + )); + + Metrics { cell } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + metrics: &mut Metrics, + window: &mut Window, + cx: &mut App, + ) { + window.paint_quad(fill(bounds, self.appearance.background)); + + for (index, row) in self.screen.rows.iter().enumerate() { + let origin = bounds.origin + point(px(0.), metrics.cell.height * index as f32); + if origin.y > bounds.bottom() { + break; + } + + let colors = &self.colors[index]; + let text: String = row.iter().map(|cell| cell.ch).collect(); + + // One run per cell would shape every glyph separately; merging + // neighbours that look alike is what makes a line of plain text one + // run instead of eighty. + let mut runs: Vec = Vec::new(); + let mut last: Option = None; + for (cell, &(fg, bg)) in row.iter().zip(colors) { + let look = Look { + fg, + bg: (bg != self.appearance.background).then_some(bg), + bold: cell.style.bold, + italic: cell.style.italic, + underline: cell.style.underline, + }; + match (&last, runs.last_mut()) { + (Some(previous), Some(run)) if *previous == look => { + run.len += cell.ch.len_utf8() + } + _ => { + runs.push(look.run(cell.ch.len_utf8(), &self.appearance)); + last = Some(look); + } + } + } + + let line = window.text_system().shape_line( + SharedString::from(text), + self.appearance.font_size, + &runs, + None, + ); + let _ = line.paint_background( + origin, + metrics.cell.height, + TextAlign::Left, + None, + window, + cx, + ); + let _ = line.paint(origin, metrics.cell.height, TextAlign::Left, None, window, cx); + } + + if let Some(cursor) = self.screen.cursor { + let origin = bounds.origin + + point( + metrics.cell.width * cursor.col as f32, + metrics.cell.height * cursor.row as f32, + ); + let cell = Bounds { origin, size: metrics.cell }; + if self.focused { + window.paint_quad(fill(cell, self.appearance.cursor)); + } else { + let mut hollow = + gpui::outline(cell, self.appearance.cursor, gpui::BorderStyle::Solid); + hollow.border_widths = px(1.).into(); + window.paint_quad(hollow); + } + } + } +} + +/// Everything about a cell that decides which run it belongs to. +/// +/// Two adjacent cells share a run exactly when their `Look`s are equal, which +/// is a single comparison rather than a rule spread across five fields. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Look { + fg: Hsla, + bg: Option, + bold: bool, + italic: bool, + underline: bool, +} + +impl Look { + fn run(&self, len: usize, appearance: &Appearance) -> TextRun { + TextRun { + len, + font: Font { + weight: if self.bold { FontWeight::BOLD } else { appearance.font.weight }, + style: if self.italic { gpui::FontStyle::Italic } else { appearance.font.style }, + ..appearance.font.clone() + }, + color: self.fg, + background_color: self.bg, + underline: self.underline.then(|| UnderlineStyle { + color: Some(self.fg), + thickness: px(1.), + wavy: false, + }), + strikethrough: None, + } + } +} diff --git a/crates/zeddy/tests/live_session.rs b/crates/zeddy/tests/live_session.rs new file mode 100644 index 00000000..e26c0a57 --- /dev/null +++ b/crates/zeddy/tests/live_session.rs @@ -0,0 +1,74 @@ +//! Smoke tests against a real herdr daemon. +//! +//! Ignored by default: they start zeddy's private backend, run a shell in it, +//! and are therefore neither hermetic nor fast. Run them when the herdr pin +//! moves, which is the moment the CLI coupling in `zeddy-herdr::stream` can +//! break without any unit test noticing. +//! +//! cargo test -p zeddy --test live_session -- --ignored --nocapture + +use std::time::{Duration, Instant}; + +use zeddy_herdr::{Geometry, Namespace, Sidecar, control::Client}; +use zeddy_vt::{Size, Terminal}; + +fn client() -> Client { + let herdr = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/herdr") + .canonicalize() + .expect("build zeddy first so herdr is vendored beside it"); + Client::new(Sidecar::at(herdr).expect("sidecar"), Namespace::private()) +} + +#[test] +#[ignore = "needs a real herdr daemon"] +fn a_shell_paints_something_within_a_few_seconds() { + let client = client(); + client.connect(Duration::from_secs(10)).expect("the private daemon comes up"); + + let cwd = std::env::temp_dir(); + let workspace = client.open_workspace(&cwd, Some("zeddy-live")).expect("a workspace"); + let session = client.start_session(&workspace, None).expect("a session"); + println!("session {} in {workspace}", session.id); + + let size = Size::new(80, 24); + let attachment = + client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); + let (mut frames, mut input) = attachment.split(); + + let mut terminal = Terminal::new(size); + input.send(b"echo zeddy-live-marker\r").expect("send"); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut seen = 0; + while Instant::now() < deadline { + match frames.next_frame().expect("the stream stays valid") { + Some(frame) => { + seen += 1; + println!( + "frame {} full={} {}x{} {} bytes", + frame.seq, + frame.full, + frame.geometry.cols, + frame.geometry.rows, + frame.bytes.len() + ); + if frame.full { + terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); + } + terminal.feed(&frame.bytes); + if terminal.screen().to_text().contains("zeddy-live-marker") { + break; + } + } + None => panic!("the stream closed after {seen} frames"), + } + } + + let text = terminal.screen().to_text(); + println!("--- screen ---\n{text}\n--- end ---"); + let _ = client.close_session(&session.id); + + assert!(seen > 0, "no frames arrived at all"); + assert!(text.contains("zeddy-live-marker"), "the echo never reached the screen"); +} diff --git a/docs/adr/0001-a-private-herdr.md b/docs/adr/0001-a-private-herdr.md new file mode 100644 index 00000000..df5f8ef9 --- /dev/null +++ b/docs/adr/0001-a-private-herdr.md @@ -0,0 +1,41 @@ +# 0001 — Sessions live in a private herdr + +## Decision + +zeddy runs its own herdr daemon in a namespace it owns: its own socket, XDG +directories, session name, and log, all under `~/.local/state/zeddy/herdr`. The +executable is the one vendored beside zeddy's binary, resolved by path. zeddy +never discovers, attaches to, stops, upgrades, or writes the user's own herdr. + +## Why + +The backend is infrastructure zeddy hides, not a feature zeddy exposes. Sharing +the user's daemon would mean zeddy's sessions and the user's sessions in one +list, zeddy's version pin constraining the user's upgrades, and a `herdr server +stop` typed in one of zeddy's own terminals killing the window's backend. + +Resolving by path rather than through `PATH` follows from the same thing: a +herdr the user installed is theirs, and picking it up would make zeddy's backend +version depend on the machine. The frame stream rides herdr's *command line*, +which carries no compatibility promise, so the version is pinned exactly rather +than as a floor. + +`Namespace::env` clears `HERDR_SESSION`, `HERDR_PANE_ID`, and their siblings +rather than merely overriding what it sets. zeddy is frequently launched *from* +a herdr pane, and an inherited selector would otherwise point a frame stream at +a daemon the control plane is not talking to. + +## What this rules out + +- Attaching zeddy to a session the user started in their own herdr. That is a + real thing to want, and it is not free: it means two version pins, two + lifetimes, and a shared list. It would be a new decision, not an extension of + this one. +- A backend administration surface. There is nothing here for a user to + configure, so there is no page for configuring it. + +## Revisit if + +Sharing a daemon with the user's own herdr becomes a request rather than a +hypothesis — at which point the namespace stays and gains a second, adopted +member rather than being removed. diff --git a/docs/adr/0002-the-zed-layer.md b/docs/adr/0002-the-zed-layer.md new file mode 100644 index 00000000..c289d0cd --- /dev/null +++ b/docs/adr/0002-the-zed-layer.md @@ -0,0 +1,44 @@ +# 0002 — The Zed layer, and what it costs + +## Decision + +zeddy depends on four crates from one pinned Zed revision: `gpui`, +`gpui_platform`, `ui`, and `theme`. It does **not** depend on Zed's `workspace` +crate. The sidebar, the tab strip, and the pane layout are zeddy's own, about +three hundred lines between them. + +zeddy is therefore GPL-3.0-or-later. + +## Why not `workspace` + +`workspace` is where Zed's docks, pane groups, splits, and tab bar live, and +taking it would have meant not writing any of the chrome. Its transitive closure +inside Zed is **88 crates** — `client`, `project`, `language`, `remote`, `db`, +`telemetry`, `node_runtime` among them. Constructing a `Workspace` needs an +`AppState` carrying a collab client, a user store, a language registry, and a +sqlite database, none of which zeddy has any use for. The two modes zeddy +actually wants are a fixed-width column and a horizontal strip. + +`ui` + `theme` close over 21 crates instead, and that closure is worth it: it is +what makes zeddy's buttons, labels, tabs, and colours Zed's own rather than a +re-implementation that looks almost right. + +## The licence, stated plainly + +`gpui` is Apache-2.0. `ui`, `theme`, and `workspace` are all GPL-3.0-or-later, +and zeddy links `ui` and `theme` directly. zeddy is GPL-3.0-or-later as a +result, and so is any native plugin, which links the same objects. This is a +consequence of the decision above, not an independent choice — dropping `ui` and +`theme` for a hand-written kit over Apache-2.0 `gpui` is the whole of what it +would take to change it. + +## Two things this pins + +- **Only `zeddy` may name `gpui_platform`.** A plugin that linked the platform + backend would register a second application with the window server. +- **`gpui_platform` must be built with `font-kit`.** It is not in that crate's + default features, and without it macOS silently gets a text system that + rasterises nothing: every quad, icon, and border paints correctly and not one + glyph appears, with the explanation behind a `log::warn!` that an app with no + logger never sees. `fonts::text_renders` checks the result at startup and + refuses to open a window that cannot show text. diff --git a/docs/adr/0003-two-plugin-tiers.md b/docs/adr/0003-two-plugin-tiers.md new file mode 100644 index 00000000..c9833aed --- /dev/null +++ b/docs/adr/0003-two-plugin-tiers.md @@ -0,0 +1,47 @@ +# 0003 — Two plugin tiers + +## Decision + +A plugin is `kind = "native"` (a `cdylib` whose GPUI view is mounted directly in +zeddy's element tree) or `kind = "web"` (a manifest and an entry document in a +webview). Both contribute the same thing: a pane. Nothing above +`zeddy-plugin-host` asks which tier a pane came from. + +## Why two + +The brief was "plugins anyone can author" *and* "a star map plugin". Those are +different requirements and one runtime cannot honestly serve both. + +- Anyone can author a web plugin: a manifest and an HTML file, no toolchain, no + ABI, and a sandbox. Its pane is composited rather than painted, so it runs a + frame behind the terminal next to it. For a clock, invisible. For a star map + being panned, not. +- A native plugin is on zeddy's own frame path — the same scrolling, resizing, + focus, input, and painting as a built-in view, because it *is* an ordinary + view. The cost is that authoring means Rust against a pinned GPUI ABI, and + installing one is installing native code. + +Shipping only the native tier would have meant "authorable by anyone" was not +true. Shipping only the web tier would have meant "a star map" was not true. + +## What was rejected + +**WASM components with a host-drawn UI contract.** Language-agnostic and +sandboxed, and it needs a display-list or UI-RPC layer between the plugin and +the renderer. That layer is the thing that makes a plugin pane feel unlike the +rest of the window, and it would have to exist even for plugins that do not need +a sandbox. Two honest tiers beat one dishonest one. + +## Consequences + +- `native_abi` in the manifest must equal zeddy's **exactly**. There is no + compatibility range and there is not going to be one: a mismatch is a vtable + from a different compilation, and the failure mode is a crash rather than a + wrong answer. +- Native libraries are **never unloaded**. A plugin's views hold vtables that + live in its library, and there is no moment at which zeddy reliably knows the + last one is gone. `zeddy-plugin-host` leaks the `Library` on purpose; a reload + brings a new generation up and swaps it in. Memory is the cost, and it is the + cheap side of that trade. +- Plugin data lives outside the plugin directory and survives replacement, + because a reload replaces the directory. diff --git a/docs/adr/0004-the-vt-core.md b/docs/adr/0004-the-vt-core.md new file mode 100644 index 00000000..b12dc42e --- /dev/null +++ b/docs/adr/0004-the-vt-core.md @@ -0,0 +1,40 @@ +# 0004 — alacritty's VT core, not libghostty + +## Decision + +`zeddy-vt` wraps `alacritty_terminal` — Zed's fork, at the revision Zed's own +terminal uses. Bytes in, a `Screen` out. That is the whole public surface. + +## Why + +libghostty-vt is the faster parser and is what a terminal built for raw speed +would reach for. It also needs an exact Zig version and, on macOS, Xcode's Metal +toolchain, before `cargo build` does anything. zeddy's renderer is built on Zed's +frontend, and taking Zed's parser means the grid semantics the renderer assumes +and the grid semantics the parser produces already agree. + +The traffic zeddy parses is also not what that speed is for. herdr's frame +stream is a *re-render of its own emulated grid* — cell-addressed writes with +normalised SGR, at herdr's repaint rate — not the raw output of the program in +the PTY. The parser is not the bottleneck on that path. + +## Snapshots, not borrows + +`Terminal::screen` copies. A borrowed grid would be faster and would tie the +render pass to the lifetime of an emulator owned by a different thread than the +one painting. At the sizes a terminal runs — a few thousand cells — the copy is +not what makes a frame slow. + +## No scrollback + +`scrolling_history` is zero. herdr's frame stream sends the viewport and has no +way to move it back through history, so a scrollback buffer here would be one +nothing can ever scroll to. History, when zeddy grows it, comes from the control +plane and is a different rendering. + +## Colour is not resolved here + +A cell carries `Default`, `Indexed(n)`, or `Rgb`. What those *are* belongs to +the theme, and resolving them in this crate would hard-code one. `zeddy::palette` +is where it happens, which makes a theme switch a re-render rather than a +re-parse. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..34fb20d9 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,11 @@ +# Architecture decisions + +One file per decision that cost something to make and would otherwise be +re-litigated by the next person to read the code. Not a design document and not +a changelog: each one records what was chosen, what it rules out, and what would +have to change for it to be worth revisiting. + +- [0001 — Sessions live in a private herdr](0001-a-private-herdr.md) +- [0002 — The Zed layer, and what it costs](0002-the-zed-layer.md) +- [0003 — Two plugin tiers](0003-two-plugin-tiers.md) +- [0004 — alacritty's VT core, not libghostty](0004-the-vt-core.md) diff --git a/plugins/clock/index.html b/plugins/clock/index.html new file mode 100644 index 00000000..823df190 --- /dev/null +++ b/plugins/clock/index.html @@ -0,0 +1,37 @@ + + +Clock + + +--:--:-- + diff --git a/plugins/clock/zeddy-plugin.toml b/plugins/clock/zeddy-plugin.toml new file mode 100644 index 00000000..ddbc0902 --- /dev/null +++ b/plugins/clock/zeddy-plugin.toml @@ -0,0 +1,6 @@ +manifest_version = 1 +id = "com.example.clock" +name = "Clock" +version = "0.1.0" +kind = "web" +entry = "index.html" diff --git a/plugins/hello/Cargo.lock b/plugins/hello/Cargo.lock new file mode 100644 index 00000000..dfc0aab6 --- /dev/null +++ b/plugins/hello/Cargo.lock @@ -0,0 +1,3912 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "bzip2", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" +dependencies = [ + "bitflags 2.13.1", + "block", + "cfg-if", + "core-foundation", + "libc", +] + +[[package]] +name = "core-video" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" +dependencies = [ + "block", + "core-foundation", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gpui" +version = "0.2.2" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "bindgen", + "bitflags 2.13.1", + "chrono", + "collections", + "core-video", + "ctor", + "derive_more", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", + "gpui_util", + "heapless", + "http_client", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "num_cpus", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand", + "raw-window-handle", + "refineable", + "regex", + "resvg", + "scheduler", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "stacksafe", + "strum", + "sum_tree", + "taffy", + "thiserror 2.0.20", + "tracing", + "ttf-parser", + "url", + "usvg", + "util_macros", + "uuid", + "waker-fn", + "web-time", + "windows", + "ztracing", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hello" +version = "0.1.0" +dependencies = [ + "zeddy-plugin", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand", + "web-time", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "heapless", + "log", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "sval" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" + +[[package]] +name = "sval_buffer" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "taffy" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c034e05f6ee85a12daa63863c2245797715075c70649947aa0da54f3f2ab1d0f" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[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-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeddy-plugin" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "toml", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/plugins/hello/Cargo.toml b/plugins/hello/Cargo.toml new file mode 100644 index 00000000..2b997dc8 --- /dev/null +++ b/plugins/hello/Cargo.toml @@ -0,0 +1,22 @@ +# A native zeddy plugin: one trait, one macro, one manifest. +# +# Deliberately not a member of zeddy's workspace. A plugin is built by whoever +# wrote it, against a released zeddy, and building it here would hide the fact +# that the contract has to hold across two separate builds. +# Its own workspace root, so it is built the way a plugin author builds it: +# `cargo build` in this directory, against a released zeddy-plugin. +[workspace] + +[package] +name = "hello" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-or-later" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# A real plugin points this at a released revision: +# zeddy-plugin = { git = "https://github.com/rengwu/chartr-zeddy", rev = "" } +zeddy-plugin = { path = "../../crates/zeddy-plugin" } diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs new file mode 100644 index 00000000..8391b7fa --- /dev/null +++ b/plugins/hello/src/lib.rs @@ -0,0 +1,61 @@ +//! The smallest complete native zeddy plugin. +//! +//! Its view is an ordinary GPUI view mounted directly in zeddy's element tree: +//! the same frame path, input, and painting as the terminal in the next tab. +//! Nothing here is a shim — `div()` is GPUI's own `div()`, and the pane it +//! returns is an `AnyView` zeddy renders without a renderer in between. + +use zeddy_plugin::{ + Host, PaneKey, Plugin, Registrar, gpui, + gpui::{Context, IntoElement, Window, div, prelude::*, px, rgb}, + register, +}; + +struct Hello { + host: Host, +} + +impl Plugin for Hello { + const ID: &'static str = "com.example.hello"; + + fn new(host: Host, _: &mut gpui::App) -> Self { + Self { host } + } + + fn activate(&mut self, registrar: &mut Registrar, _: &mut gpui::App) { + // Declaring is not building: zeddy calls `view` only when the pane is + // actually shown, so contributing a pane costs a string until then. + registrar.add_pane("main", "Hello"); + } + + fn view(&mut self, _: &PaneKey, _: &mut Window, cx: &mut gpui::App) -> gpui::AnyView { + let data_dir = self.host.data_dir.display().to_string(); + cx.new(|_| HelloView { data_dir }).into() + } +} + +struct HelloView { + data_dir: String, +} + +impl Render for HelloView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_2() + .items_center() + .justify_center() + .text_color(rgb(0xd0d0d0)) + .child("Hello from a native plugin.") + .child( + div() + .text_size(px(12.)) + .text_color(rgb(0x808080)) + .child(format!("data: {}", self.data_dir)), + ) + } +} + +register!(Hello); diff --git a/plugins/hello/zeddy-plugin.toml b/plugins/hello/zeddy-plugin.toml new file mode 100644 index 00000000..8696c4db --- /dev/null +++ b/plugins/hello/zeddy-plugin.toml @@ -0,0 +1,11 @@ +manifest_version = 1 +id = "com.example.hello" +name = "Hello" +version = "0.1.0" +kind = "native" +# The Cargo library stem. zeddy appends this platform's extension, so one +# manifest covers .dylib, .so, and .dll. +library = "hello" +# Must equal zeddy's own. Rust and GPUI objects cross the library boundary, so +# there is no compatibility range and a mismatch is refused at load. +native_abi = 1 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..9621b42b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pinned so the GPUI revision below and this toolchain never drift apart. +# GPUI needs a recent stable; nothing here needs nightly. +[toolchain] +channel = "1.96.0" +components = ["rustfmt", "clippy"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..dd01d76b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +# Matches the shape the code was written in: wide enough that a signature and a +# doc-commented match arm each fit on one line. +max_width = 100 +use_small_heuristics = "Max" diff --git a/vendor/herdr/LICENSE b/vendor/herdr/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/vendor/herdr/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/herdr/fetch.sh b/vendor/herdr/fetch.sh new file mode 100755 index 00000000..f5f0c4ae --- /dev/null +++ b/vendor/herdr/fetch.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Vendors the herdr executable zeddy ships as its backend. +# +# A maintenance step, run by hand when the pin moves — never by a build. This is +# the only thing in zeddy that reaches the network, and it is not on the path of +# `cargo build`. +# +# sh vendor/herdr/fetch.sh # this machine's target +# sh vendor/herdr/fetch.sh … # named targets +# +# Each executable lands at `vendor/herdr//herdr`, which is gitignored: +# they belong to herdr, not to this history. `crates/zeddy/build.rs` copies the +# one for the target being built in beside the zeddy binary. +# +# The version is not a flag. It is read from `SUPPORTED_HERDR_VERSION`, the one +# place zeddy pins herdr, so a vendored binary and the client that drives it +# cannot disagree about which release this is. + +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) + +version=$(sed -n 's/^pub const SUPPORTED_HERDR_VERSION: &str = "\(.*\)";$/\1/p' \ + "$root/crates/zeddy-herdr/src/lib.rs") +[ -n "$version" ] || { + echo "cannot read SUPPORTED_HERDR_VERSION from crates/zeddy-herdr/src/lib.rs" >&2 + exit 1 +} + +# herdr publishes no Windows build, and `zeddy-herdr` does not compile there +# either — its control plane is a Unix domain socket. +asset_for() { + case "$1" in + aarch64-apple-darwin) echo "herdr-macos-aarch64" ;; + x86_64-apple-darwin) echo "herdr-macos-x86_64" ;; + aarch64-unknown-linux-gnu) echo "herdr-linux-aarch64" ;; + x86_64-unknown-linux-gnu) echo "herdr-linux-x86_64" ;; + *) return 1 ;; + esac +} + +host_target() { + machine=$(uname -m) + case "$(uname -s)" in + Darwin) case "$machine" in arm64) echo "aarch64-apple-darwin" ;; *) echo "x86_64-apple-darwin" ;; esac ;; + Linux) case "$machine" in aarch64) echo "aarch64-unknown-linux-gnu" ;; *) echo "x86_64-unknown-linux-gnu" ;; esac ;; + *) echo "unsupported host: $(uname -s)" >&2; exit 1 ;; + esac +} + +targets=${*:-$(host_target)} + +for target in $targets; do + asset=$(asset_for "$target") || { + echo "no herdr release asset for $target" >&2 + exit 1 + } + dir="$here/$target" + mkdir -p "$dir" + url="https://github.com/herdrdev/herdr/releases/download/v$version/$asset" + echo "fetching herdr $version for $target" + curl -fsSL "$url" -o "$dir/herdr" + chmod +x "$dir/herdr" +done + +curl -fsSL "https://raw.githubusercontent.com/herdrdev/herdr/v$version/LICENSE" \ + -o "$here/LICENSE" +echo "vendored herdr $version" From 7dfdc58039246b5e342a2fa22d47bbc63da45f16 Mon Sep 17 00:00:00 2001 From: John Goh Date: Mon, 31 Aug 2026 19:13:27 +0800 Subject: [PATCH 002/110] Implement Zed-shaped Chartr workspaces --- .github/workflows/ci.yml | 48 + .plan/maps/chartr-zeddy-workspace/map.md | 34 + .plan/maps/chartr-zeddy-workspace/spec.md | 345 ++ Cargo.lock | 1610 ++++++- Cargo.toml | 16 +- README.md | 213 +- crates/zeddy-herdr/src/control.rs | 113 +- crates/zeddy-herdr/src/namespace.rs | 70 +- crates/zeddy-plugin-host/src/lib.rs | 303 +- crates/zeddy-plugin/src/lib.rs | 52 +- crates/zeddy-plugin/src/manifest.rs | 85 +- crates/zeddy/Cargo.toml | 14 + .../ibm-plex-mono/IBMPlexMono-Regular.ttf | Bin 0 -> 173052 bytes .../assets/fonts/ibm-plex-mono/LICENSE.txt | 93 + crates/zeddy/build.rs | 21 + crates/zeddy/src/actions.rs | 68 + crates/zeddy/src/app.rs | 3763 +++++++++++++++-- crates/zeddy/src/chrome.rs | 69 +- crates/zeddy/src/chrome/sidebar.rs | 272 +- crates/zeddy/src/chrome/tabs.rs | 122 +- crates/zeddy/src/fonts.rs | 60 +- crates/zeddy/src/item.rs | 78 + crates/zeddy/src/keymap.rs | 255 ++ crates/zeddy/src/main.rs | 84 +- crates/zeddy/src/mode.rs | 5 +- crates/zeddy/src/persistence.rs | 294 ++ crates/zeddy/src/session.rs | 30 +- crates/zeddy/src/settings.rs | 513 +++ crates/zeddy/src/space.rs | 841 ++++ crates/zeddy/src/spaces.rs | 449 ++ crates/zeddy/src/web_plugin.rs | 566 +++ crates/zeddy/src/workspace.rs | 890 ++++ crates/zeddy/tests/live_session.rs | 88 +- docs/acceptance.md | 64 + docs/adr/0001-a-private-herdr.md | 19 +- .../0005-spaces-follow-zed-multi-workspace.md | 47 + docs/adr/README.md | 1 + plugins/clock/index.html | 9 +- plugins/clock/settings.html | 33 + plugins/clock/zeddy-plugin.toml | 10 +- plugins/hello/Cargo.lock | 399 +- plugins/hello/Cargo.toml | 1 + plugins/hello/src/lib.rs | 8 +- plugins/hello/zeddy-plugin.toml | 8 +- 44 files changed, 11374 insertions(+), 689 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .plan/maps/chartr-zeddy-workspace/map.md create mode 100644 .plan/maps/chartr-zeddy-workspace/spec.md create mode 100644 crates/zeddy/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf create mode 100644 crates/zeddy/assets/fonts/ibm-plex-mono/LICENSE.txt create mode 100644 crates/zeddy/src/actions.rs create mode 100644 crates/zeddy/src/item.rs create mode 100644 crates/zeddy/src/keymap.rs create mode 100644 crates/zeddy/src/persistence.rs create mode 100644 crates/zeddy/src/settings.rs create mode 100644 crates/zeddy/src/space.rs create mode 100644 crates/zeddy/src/spaces.rs create mode 100644 crates/zeddy/src/web_plugin.rs create mode 100644 crates/zeddy/src/workspace.rs create mode 100644 docs/acceptance.md create mode 100644 docs/adr/0005-spaces-follow-zed-multi-workspace.md create mode 100644 plugins/clock/settings.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..93ca4646 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15, ubuntu-24.04] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential clang cmake pkg-config \ + libasound2-dev libfontconfig-dev libglib2.0-dev libssl-dev \ + libva-dev libvulkan1 libwayland-dev libx11-xcb-dev \ + libxkbcommon-x11-dev libzstd-dev libwebkit2gtk-4.1-dev + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.95.0 + + - uses: Swatinem/rust-cache@v2 + + - name: Fetch the pinned Herdr sidecar + run: sh vendor/herdr/fetch.sh + + - name: Check formatting + run: cargo fmt --all --check + + - name: Build and test + run: cargo test --workspace --locked --no-fail-fast + + - name: Build the native plugin contract example + run: cargo check --manifest-path plugins/hello/Cargo.toml --locked diff --git a/.plan/maps/chartr-zeddy-workspace/map.md b/.plan/maps/chartr-zeddy-workspace/map.md new file mode 100644 index 00000000..f987a733 --- /dev/null +++ b/.plan/maps/chartr-zeddy-workspace/map.md @@ -0,0 +1,34 @@ +# Chartr workspace rewrite + +## Destination + +Chartr is a coherent, themeable multi-space terminal and plugin workspace whose +ownership, panes, actions, settings, persistence, and interaction conventions +closely follow Zed while retaining Chartr's product behavior and visual identity. +The settled product contract is recorded in [the specification](./spec.md). + +## Notes + +- [Specification](./spec.md) +- The application is user-facing **Chartr**, but this rewrite keeps configuration, + state, and runtime data isolated under the `chartr-zeddy` namespace. +- Zed is the architectural, component, accessibility, and interaction reference. + Go Chartr is the current visual-design reference; Chartr-rs is the settings and + Herdr-lifecycle reference. + +## Decisions so far + + + +## Not yet specified + + + +## Out of scope + +- Windows support, pending a non-Unix Herdr transport. +- Terminal scrollback, pending a real Herdr history source. +- Cross-space item movement. +- Terminal mirroring, preview tabs, and pinned tabs. +- Automatic migration from existing Chartr installations. +- Phosphor or user-selectable application-control icon sets. diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md new file mode 100644 index 00000000..68fcc284 --- /dev/null +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -0,0 +1,345 @@ +# Chartr workspace rewrite specification + +## Problem Statement + +Chartr's current interface is visually inconsistent and structurally fragile. +Controls and tab chrome are misaligned, reusable Zed components and semantic theme +tokens are not applied consistently, and some item types—including plugin tabs—do +not expose expected controls such as close buttons. + +The underlying ownership model is also incorrect. Ad-hoc sessions and opened +plugin views can appear in every space even though an opened tab must belong to +exactly one space and one pane. The existing flat active-item model cannot support +Zed-style nested panes, cross-pane tab drag and drop, directional focus, resizing, +or reliable restoration. + +Core application behavior is incomplete: closing shortcuts are missing; settings +do not exist; web plugins are advertised but not hosted; workspace state is not +fully persisted; and Herdr transport failures can surface as an unhandled broken +pipe instead of the proven recovery behavior from Chartr-rs. + +## Solution + +Build Chartr around a focused implementation of Zed's multi-workspace model. The +application window owns multiple independent spaces. Each space owns a recursive +pane group; each pane exclusively owns ordered item instances; each terminal or +plugin tab is an item. A catalog may advertise plugin factories globally, but an +opened plugin instance belongs to exactly one pane and space. + +Provide complete Zed-style pane behavior: nested splits, divider resizing, +directional focus, tab reordering and movement between panes, edge-drop splitting, +joining, zooming/maximizing, contextual commands, a command palette, and complete +layout restoration. Terminals are non-cloneable; plugins may explicitly declare +clone support. Cross-space movement is not supported. + +Offer tabbed and sidebar projections over the same model. Tabbed mode shows one +active space and local tab bars for each pane. Sidebar mode can show all spaces or +only the active space, visually groups tabs by their pane tree, and uses compact +pane headers and drop targets instead of duplicate local tab labels. Presentation +never changes item ownership. + +Use Zed's existing GPUI, UI, and theme crates and their components, semantic +colors, spacing, typography, focus, accessibility, menu, modal, notification, +and drag-and-drop conventions. Chartr owns product composition, not replacement +UI primitives. Go Chartr supplies the visual reference through normal semantic +`Chartr Light` and `Chartr Dark` themes; `Chartr Dark` is the fixed default. + +Present Settings inside the main window as Chartr-rs does, while implementing a +focused Zed-shaped typed settings store, page catalog, field renderer registry, +semantic action/keymap system, atomic persistence, live updates, and plugin page +contributions. Settings are user-global in this version. + +Restore Chartr-rs's small, explicit Herdr lifecycle: fresh control connections, +per-session stream failure states, one clean backend restart, a crash-loop guard, +and a non-destructive Retry action. Avoid a generic supervisor or backend +administration surface. + +Persist application-owned layout and item metadata in a versioned SQLite store, +while user-editable settings, keymaps, and themes remain files. Keep all data +isolated under the `chartr-zeddy` namespace and do not import existing Chartr +configuration automatically. + +## User Stories + +1. As a Chartr user, I want every open tab to belong to one space, so that switching spaces never duplicates sessions or plugin views. +2. As a Chartr user, I want every tab to belong to one pane, so that its position and focus are unambiguous. +3. As a Chartr user, I want one permanent Ad-hoc space, so that I can open a terminal without first selecting a project folder. +4. As a Chartr user, I want Ad-hoc terminals to start in my home directory by default, so that folderless sessions have a predictable working directory. +5. As a Chartr user, I want to configure the Ad-hoc working directory, so that folderless sessions suit my workflow. +6. As a Chartr user, I want at most one space per canonical folder, so that aliases and symlinks do not create duplicate projects. +7. As a Chartr user, I want to rename a space's displayed label without changing its folder identity, so that my workspace list is understandable. +8. As a Chartr user, I want missing folders retained as unavailable spaces, so that transient mounts or moved folders do not destroy layout state. +9. As a Chartr user, I want to locate a missing space folder, so that I can reconnect its saved workspace state. +10. As a Chartr user, I want removing a space to leave its folder untouched, so that workspace cleanup cannot delete project data. +11. As a Chartr user, I want new sessions to open in the active pane of the targeted space, so that placement is predictable. +12. As a Chartr user, I want an inactive space's add control to activate that space before creating its session, so that sessions never enter the wrong owner. +13. As a Chartr user, I want nested horizontal and vertical splits, so that I can arrange several terminals and tools at once. +14. As a Chartr user, I want to resize split dividers, so that each pane receives useful screen space. +15. As a Chartr user, I want directional pane focus, so that I can navigate a split layout from the keyboard. +16. As a Chartr user, I want to reorder tabs within a pane, so that related work stays together. +17. As a Chartr user, I want to drag a tab between panes, so that I can reorganize the current space without recreating its item. +18. As a Chartr user, I want to drop a tab on a pane edge to create a split, so that advanced layouts are direct and discoverable. +19. As a Chartr user, I want joining a pane to move its items into an adjacent pane, so that changing layout never kills work. +20. As a Chartr user, I want empty panes retained until I explicitly join them, so that deliberate drop targets do not disappear. +21. As a Chartr user, I want at least one root pane to remain, so that an empty space is still usable. +22. As a Chartr user, I want to zoom or maximize a pane, so that I can temporarily concentrate on one item. +23. As a Chartr user, I want terminals never to be cloned or mirrored, so that one session is never represented by multiple terminal tabs. +24. As a plugin author, I want to declare whether my item supports cloning, so that split cloning is safe and intentional. +25. As a Chartr user, I want pane layouts and split ratios restored after switching spaces, so that every space behaves like an independent editor window. +26. As a Chartr user, I want pane layouts and active items restored after relaunch, so that restarting Chartr does not destroy organization. +27. As a Chartr user, I want tabbed mode to show only the active space, so that its compact chrome remains focused. +28. As a Chartr user, I want each pane in tabbed mode to have its own tab bar, so that tab ownership is visible. +29. As a Chartr user, I want sidebar mode to show either all spaces or only the active space, so that I can choose overview or focus. +30. As a Chartr user, I want All Spaces to be the initial sidebar mode, so that a fresh installation exposes the whole cockpit. +31. As a Chartr user, I want pane-owned tabs visually grouped in the sidebar, so that split membership remains clear without duplicate pane tab bars. +32. As a Chartr user, I want compact pane headers and drop targets in sidebar mode, so that advanced pane operations remain available. +33. As a Chartr user, I want selecting an item in an inactive space to activate its space, pane, and item together, so that selection is one coherent action. +34. As a Chartr user, I want the sidebar width and presentation modes persisted, so that the application retains my preferred chrome. +35. As a Chartr user, I want the top-level visual pane group to be closable, so that I can end everything beneath it deliberately. +36. As a Chartr user, I want confirmation before an operation kills multiple sessions, so that bulk actions are not accidentally destructive. +37. As a Chartr user, I want closing one terminal tab to terminate its Herdr session immediately, so that abandoned processes do not accumulate. +38. As a Chartr user, I want `Cmd+W` on macOS and `Ctrl+W` on Linux to close the active tab, so that closing follows familiar application behavior. +39. As a Chartr user, I want a closed session's explicitly bound plugin items to close too, so that dependent tools never outlive their subject. +40. As a Chartr user, I want closing a plugin tab to destroy that view instance, so that it no longer consumes active UI state. +41. As a Chartr user, I want normal application exit to detach sessions, so that quitting the UI does not terminate intentional long-running work. +42. As a Chartr user, I want a setting that can terminate sessions on application exit, so that I may choose stricter cleanup. +43. As a Chartr user, I want removing a space to terminate everything it owns after confirmation, so that the ownership boundary has clear lifecycle semantics. +44. As a Chartr user, I want closing Settings with `Cmd/Ctrl+W` to return to my previous item, so that a hidden terminal is never killed accidentally. +45. As a Chartr user, I want plugin contributions to be globally discoverable but opened instances to remain space-owned, so that catalogs do not duplicate live tabs. +46. As a Chartr user, I want separate plugin instances in different spaces when supported, so that each project can have independent tools. +47. As a plugin author, I want to declare singleton or multi-instance behavior, so that Chartr enforces my contribution's valid lifecycle. +48. As a Chartr user, I want a per-space singleton plugin to focus its existing pane when reopened, so that it is not silently moved or duplicated. +49. As a plugin author, I want a stable owning-space context, so that my view never retargets when another space becomes active. +50. As a plugin author, I want to bind explicitly to one session when required, so that session-sensitive behavior is deterministic. +51. As a Chartr user, I want restorable plugin items to return after relaunch, so that supported tools participate in workspace persistence. +52. As a Chartr user, I want unrestorable plugin items omitted with a summary, so that missing plugins do not create permanent broken tabs. +53. As a plugin author, I want to contribute a lazy settings page, so that configuration appears only when my plugin provides it. +54. As a Chartr user, I want native plugins labeled as fully trusted code, so that their security model is honest. +55. As a Chartr user, I want web plugin permissions visible before enablement, so that I understand their authority. +56. As a web plugin author, I want declared read/write access within my owning project's folder, so that useful project tools are possible in safe mode. +57. As a web plugin author, I want plugin-specific data storage, so that my plugin can persist data without broad filesystem access. +58. As a Chartr user, I want folderless-space web plugins restricted to plugin data in safe mode, so that `$HOME` is not implicitly exposed. +59. As a Chartr user, I want to grant unrestricted filesystem access to one web plugin through unsafe mode, so that capable plugins remain possible without weakening every plugin. +60. As a Chartr user, I want no global unsafe switch, so that one grant cannot silently authorize unrelated plugins. +61. As a web plugin author, I want declared host actions for network and process access, so that powerful behavior is mediated and visible. +62. As a session-bound web plugin, I want declared access to metadata and terminal input for only my bound session, so that session integrations remain scoped. +63. As a Chartr user, I want permission revocation to close live plugin instances and revoke their broker, so that reduced authority takes effect immediately. +64. As a Chartr user, I want native and web plugin panes both to work, so that no advertised plugin tier ends in a placeholder. +65. As a Chartr user, I want disabling a plugin to remove its contributions and prevent future loading, so that enablement has real effect. +66. As a Chartr user, I want Settings presented inside the main window while retaining the spaces sidebar, so that configuration feels native to Chartr-rs. +67. As a Chartr user, I want General, Appearance, Terminal, Hotkeys, and Plugins settings pages, so that the implemented product can be configured coherently. +68. As a Chartr user, I want Settings to show only implemented controls, so that no option is decorative or misleading. +69. As a Chartr user, I want settings changes applied immediately where safe, so that configuration provides direct feedback. +70. As a Chartr user, I want settings updates written atomically, so that a crash cannot corrupt preferences. +71. As a Chartr user, I want keyboard shortcuts editable in the ordinary Hotkeys page, so that customization does not require manual file editing. +72. As a Chartr user, I want contextual shortcut conflict detection, so that terminal input and workspace actions resolve predictably. +73. As a Chartr user, I want a command palette exposing workspace and pane actions, so that advanced operations are discoverable. +74. As a Chartr user, I want `Chartr Dark` as the fixed initial theme, so that the application starts with the intended identity. +75. As a Chartr user, I want `Chartr Light`, fixed theme, and light/dark/system theme-pair options, so that I can change appearance later. +76. As a Chartr user, I want user theme files loaded and refreshed, so that Chartr remains compatible with the intended Zed-style theme model. +77. As a Chartr user, I want IBM Plex Sans and IBM Plex Mono as configurable defaults, so that the Go Chartr visual reference is preserved without locking my typography. +78. As a Chartr user, I want semantic theme colors and Zed UI components everywhere, so that alternate themes remain coherent. +79. As a keyboard user, I want every drag operation to have an action-based alternative, so that pane management is not pointer-only. +80. As an accessibility user, I want reliable focus order, focus restoration, labels, contrast, and reduced-motion behavior, so that the application is operable without visual guesswork. +81. As a Chartr user, I want an affected terminal to show a clear state when its Herdr stream breaks, so that a transport failure is understandable. +82. As a Chartr user, I want reattachment offered only when Herdr confirms the same session exists, so that retry cannot silently create or target the wrong session. +83. As a Chartr user, I want Chartr to restart its private Herdr once after unexpected death, so that a transient backend crash recovers automatically. +84. As a Chartr user, I want repeated backend death to become a stable crash-loop state with Retry, so that Chartr does not restart forever. +85. As a Chartr user, I want surviving space layouts and space-bound plugins retained after backend loss, so that one backend crash does not erase unrelated workspace state. +86. As a Chartr user, I want Herdr's live session list to override stale local terminal records, so that the UI reflects processes that actually exist. +87. As a Chartr user, I want orphaned live Herdr sessions adopted into their owning space, so that detached work is not lost from the UI. +88. As a Chartr user, I want a fresh installation to open the empty Ad-hoc space without spawning a terminal, so that startup has no unnecessary process side effect. +89. As a Chartr user, I want window geometry, pane ratios, expansion state, selection, and chrome restored, so that the entire cockpit returns after relaunch. +90. As an existing Chartr user, I want Chartr-zeddy data isolated from older installations, so that the rewrite cannot corrupt or conflict with existing settings. + +## Implementation Decisions + +- The user-facing application is Chartr. Configuration, state, plugins, and the + private Herdr runtime use an isolated `chartr-zeddy` namespace for now. +- The application window follows Zed's `MultiWorkspace` responsibility and owns + ordered space entities plus one active space. +- A space is the lifecycle and persistence boundary analogous to a Zed + `Workspace`. It owns one recursive pane group, its panes, active pane, item-to- + pane index, folder identity, and workspace-local restoration state. +- A pane exclusively owns its ordered items, active item, activation history, + focus state, and drag state. Chrome never owns or reconstructs item state. +- A pane group is a recursive axis tree with horizontal/vertical members and + persisted flex ratios. Workspace-level event handling coordinates mutations. +- Items expose lifecycle, serialization, focus, close, and optional clone + behavior. A terminal session item is non-cloneable and closes destructively. +- An opened item entity may appear in only one pane and one space. Moving an item + removes it from its source pane before insertion. Cross-space moves are absent. +- Pane mutations use typed actions and pane events. Product chrome does not reach + into pane internals to mutate vectors directly. +- Dragged tabs carry their source pane and item identity. Drops reorder within a + pane, move between panes, or split at pane edges. Modifier cloning is available + only to plugin items that declare it. +- Joining a pane moves items and collapses the axis. Closing the last item retains + an empty pane; at least the root pane always remains. +- The visual sidebar group is not an item. Its close control is a bulk lifecycle + action over all descendant items. Only top-level space groups expose that bulk + control; panes expose their own Close All action. +- Single destructive item closes do not confirm. Any action that would terminate + multiple live sessions confirms with an exact count. +- The active item after removal follows Zed's activation-history behavior with a + positional fallback. +- The permanent Ad-hoc space has no folder, cannot be renamed or removed, and + defaults new sessions to the user's home directory or a configured replacement. +- Folder spaces are deduplicated by canonical path. Display names are metadata and + do not participate in identity. +- Tabbed and sidebar modes are alternate renderings of the same space/pane/item + state. Changing chrome never creates, moves, or closes an item. +- Sidebar mode persists an All Spaces or Active Space submode. Selecting an item + from another space activates its space, pane, and item as one operation. +- The sidebar is resizable with bounded width. Tabbed mode is active-space-only. +- User-visible actions are semantic GPUI actions with contextual keybindings. + Platform defaults follow Zed except that terminal focus does not override the + requested `Cmd/Ctrl+W` close behavior. +- Settings is a main-window workspace that preserves the spaces sidebar and + restores the prior focus on close; it is not an item in a pane. +- Settings serialization uses sparse optional content; runtime consumers use + resolved typed settings with complete defaults. +- One centralized settings store merges defaults and user-global configuration, + observes changes, performs atomic writes, and refreshes affected windows. +- Settings page data is declarative. Field renderers own reusable controls, while + domain code owns resolved settings behavior. +- Hotkeys are presented in Settings but backed by a contextual keymap model. The + command palette, menus, buttons, and shortcuts dispatch the same actions. +- The settings catalog contains General, Appearance, Terminal, Hotkeys, and + Plugins. Controls are omitted until their behavior exists. +- Scrollback is omitted because the current Herdr frame stream exposes only the + viewport and cannot implement genuine history. +- Zed's existing UI components and semantic styles are audited before any local + reusable component is introduced. Chartr may compose product-specific views. +- `Chartr Light` and `Chartr Dark` are standard semantic theme families. Chartr + Dark is the fixed default; users may select fixed or light/dark/system themes. +- Theme colors are resolved at render time. Feature views do not cache palettes or + embed Go Chartr color literals. +- IBM Plex Sans and Mono are bundled defaults and remain user settings. Application + controls use Zed's icon components directly; no alternate icon framework exists. +- Default density is the only exposed density initially, though semantic dynamic + spacing remains compatible with future density settings. +- The plugin catalog stores descriptors and factories, never live item instances. +- Plugin multiplicity defaults to one instance per space. Plugins explicitly opt + into multiple instances, clone-on-split, serialization, session binding, and + settings page contributions. +- Plugin items receive a stable owning space. Session-specific items bind to one + explicit session and close when that session ends. +- Plugin restoration is capability-driven. Failed item restoration produces one + non-blocking summary and collapses invalid empty branches where appropriate. +- Native plugin libraries remain loaded for process safety. Disabling removes + contributions, closes live instances after confirmation, and prevents loading on + future launches; the mapped library stays inert until exit. +- Web plugins are hosted as real pane items in isolated webviews rather than a + placeholder message. +- Safe web filesystem access is brokered, manifest-declared, canonicalized, and + constrained beneath the owning folder, including protection against symlink + escapes. Folderless safe instances receive plugin data storage only. +- Unsafe filesystem access is a persistent, explicit per-plugin grant. No global + unsafe switch exists. +- Network, process, navigation, external-link, and session operations remain typed + host actions with visible manifest declarations. Session control reaches only the + explicitly bound session. +- Permission revocation closes active instances and revokes their broker. Native + trust and web permissions are visible in Plugins settings. +- The plugin manifest and native ABI may be bumped to encode the new capabilities. + Bundled examples move with the contract; incompatible plugins fail clearly. +- Herdr control requests use a fresh Unix connection and exact handshake. There is + no long-lived reconnecting control client. +- A stream error removes the terminal command channel and renders an actionable + notice inside that item. Reattach is offered only after confirming the stable + session identity through the control plane. +- A small window-owned health state machine checks the private daemon, performs one + clean replacement, and detects a second failure within 60 seconds as a crash + loop. It exposes Retry and no backend administration UI. +- Backend loss removes terminal items and their session-bound plugins but retains + spaces, pane geometry, deliberately empty panes, and space-bound plugin items. +- Herdr is authoritative for live session existence. Orphaned sessions enter the + owning space's last-active pane; stale saved terminal items are dropped. +- Versioned SQLite persistence stores space identities, pane trees, item records, + active state, split ratios, window bounds, sidebar width/submode, chrome mode, + expansion state, and migrations. +- User-editable settings, keymaps, and themes remain files. All persistent and + runtime paths are namespaced to Chartr-zeddy; no automatic legacy import occurs. +- The supported platforms are macOS and Linux. Windows remains deferred until the + Herdr transport is abstracted beyond Unix-domain sockets. + +## Testing Decisions + +- Tests observe behavior through the highest stable seam: the window/workspace + action surface for UI behavior, serialized reload for persistence, the plugin + host contract for contributions and permissions, and a real private Herdr + process for transport behavior. Lower-level unit tests supplement rather than + replace those seams. +- Ownership tests prove that an item entity is present in exactly one pane and one + space after add, reorder, cross-pane move, split-edge drop, join, close, restore, + and failed restore operations. +- Pane-group tests cover recursive split construction, flex resizing, directional + adjacency/focus, edge-drop placement, join/collapse, empty-root invariants, + zoom/maximize state, and serialization round trips. Property tests exercise long + transformation sequences and assert tree and ownership invariants. +- Lifecycle tests assert that a single session close kills only that session; + pane join kills none; session-bound plugins cascade; bulk operations confirm; + space removal kills all owned sessions; and normal application exit detaches. +- Chrome tests assert that switching Tabbed, Sidebar/All Spaces, and Sidebar/Active + Space changes only presentation. Selecting and creating items from inactive + groups must activate the correct space and pane without duplication. +- Action tests use semantic commands and contexts, including close, Settings close, + split, join, focus, move, zoom, palette dispatch, and keybinding conflicts. +- Settings tests cover default resolution, sparse user content, atomic updates, + parse failure behavior, live observation, hotkey conflict reporting, theme + selection, plugin page discovery, and restart-bound disclosures. +- Persistence tests launch from saved state and observe restored spaces, recursive + layouts, active state, window/chrome geometry, unavailable folders, missing + sessions, orphan sessions, missing plugins, and schema migrations. +- Native plugin tests cover trust labeling, per-space singleton behavior, + multi-instance opt-in, clone capability, close, disable, settings contribution, + serialization, ABI mismatch, and restoration failure. +- Web plugin tests cover real view hosting, safe project read/write, canonical and + symlink containment, folderless storage, unsafe per-plugin access, declared + network/process/session actions, permission display, and immediate revocation. +- Herdr unit tests cover protocol framing and lifecycle transitions. Required live + tests launch the vendored private backend and cover handshake, shell painting, + close/kill, detach/adopt, broken stream, confirmed reattach, one backend restart, + and crash-loop Retry. Transport completion requires these live tests to pass. +- Visual acceptance captures Chartr Dark and Light at common window sizes for + tabbed mode, both sidebar submodes, nested panes, drag targets, empty panes, + confirmations, errors, settings, command palette, and plugin permissions. +- Visual review checks alignment, clipping, typography, semantic colors, hover, + active and focus states, pane ownership grouping, and absence of placeholders. +- Accessibility tests cover keyboard-only equivalents, focus order/restoration, + accessible labels, contrast, and reduced-motion behavior, using Zed's components + and interaction behavior as the gold standard. +- The full workspace suite must pass. A mock-only success, ignored required live + test, or knowingly broken requested flow does not satisfy the specification. + +## Out of Scope + +- Windows support and non-Unix Herdr transports. +- Cross-space tab movement or duplication. +- Terminal cloning or mirrored views. +- Preview tabs and pinned tabs. +- Terminal scrollback until Herdr provides a correct history source. +- Automatic import or shared configuration with Go Chartr or Chartr-rs. +- Multiple operating-system windows; spaces provide independent workspace + ownership within the Chartr window. +- A global unsafe mode for web plugins. +- A generic process-supervisor framework or backend administration UI. +- Phosphor compatibility or user-selectable application-control icon sets. +- Exposing non-default UI density before it has dedicated visual acceptance. + +## Further Notes + +- Zed is the architectural and interaction source of truth wherever it already + supplies a convention. Go Chartr is a visual reference, not permission to embed + fixed colors or bypass theme semantics. Chartr-rs is the behavioral precedent + for Settings presentation and Herdr recovery. +- Importing Zed's complete workspace and settings UI crates is intentionally + avoided because they carry unrelated editor, collaboration, language, remote, + database, telemetry, audio, and agent-product dependencies. Focused Chartr + implementations must still preserve the established Zed boundaries rather than + inventing a different architecture. +- The current prototype is not an API compatibility constraint. It may be replaced + wholesale when doing so produces the agreed model more directly. +- The test seams and acceptance coverage above were explicitly agreed during the + grilling session and are the completion contract for implementation. diff --git a/Cargo.lock b/Cargo.lock index f8dde7d5..0a0c0a49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,10 +125,23 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -143,7 +156,7 @@ name = "alacritty_terminal" version = "0.26.1-dev" source = "git+https://github.com/zed-industries/alacritty?rev=4c129667ce56611becdc82de6e28218c80e2e88f#4c129667ce56611becdc82de6e28218c80e2e88f" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "home", "libc", @@ -248,6 +261,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "as-slice" version = "0.2.1" @@ -276,9 +295,20 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "serde", + "serde_repr", "zbus", ] +[[package]] +name = "assets" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "gpui", + "rust-embed", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -444,6 +474,29 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic" version = "0.5.3" @@ -563,6 +616,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.71.1" @@ -658,6 +717,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -708,6 +776,16 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -773,6 +851,31 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "calloop" version = "0.14.4" @@ -825,6 +928,12 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -834,6 +943,16 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -875,7 +994,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -977,6 +1096,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "component" version = "0.1.0" @@ -1027,6 +1156,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.8.0" @@ -1054,6 +1209,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1222,6 +1387,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -1231,6 +1405,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1281,6 +1464,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "ctor" version = "1.0.13" @@ -1348,11 +1563,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1371,7 +1597,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1447,6 +1673,42 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set 0.8.0", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1491,6 +1753,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1632,6 +1903,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1656,6 +1939,27 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "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.11" @@ -1929,6 +2233,91 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1939,6 +2328,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1994,22 +2393,114 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] -name = "gl_generator" -version = "0.14.0" +name = "gio" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" dependencies = [ "khronos_api", "log", "xml-rs", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glob" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "glow" version = "0.17.0" @@ -2031,6 +2522,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -2167,17 +2669,22 @@ dependencies = [ "accesskit", "accesskit_unix", "anyhow", + "as-raw-xcb-connection", + "ashpd", "bytemuck", "calloop", "collections", + "filedescriptor", "futures", "gpui", "gpui_util", + "gpui_wgpu", "http_client", "libc", "log", "notify-rust", "oo7", + "open", "parking_lot", "raw-window-handle", "smallvec", @@ -2185,6 +2692,11 @@ dependencies = [ "strum", "url", "uuid", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-scap", + "zed-xim", ] [[package]] @@ -2324,6 +2836,7 @@ dependencies = [ "unicode-segmentation", "web-sys", "wgpu", + "zed-font-kit", ] [[package]] @@ -2354,6 +2867,58 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "half" version = "2.7.1" @@ -2388,6 +2953,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2414,6 +2988,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.9.3" @@ -2469,7 +3052,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2481,6 +3064,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.5.0" @@ -2521,6 +3114,21 @@ dependencies = [ "url", ] +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2757,6 +3365,25 @@ dependencies = [ "leaky-cow", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2781,6 +3408,45 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2933,6 +3599,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3087,6 +3764,17 @@ dependencies = [ "libc", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3104,7 +3792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -3168,6 +3856,22 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3239,6 +3943,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3448,6 +4167,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "objc" version = "0.2.7" @@ -3492,6 +4233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -3573,6 +4315,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.2.2" @@ -3648,6 +4399,18 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -3661,6 +4424,20 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc_exception" version = "0.1.2" @@ -3717,7 +4494,7 @@ dependencies = [ "blocking", "cbc", "cipher", - "digest", + "digest 0.10.7", "endi", "futures-lite", "futures-util", @@ -3730,7 +4507,7 @@ dependencies = [ "pbkdf2", "serde", "serde_bytes", - "sha2", + "sha2 0.10.9", "subtle", "zbus", "zbus_macros", @@ -3738,6 +4515,16 @@ dependencies = [ "zvariant", ] +[[package]] +name = "open" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "optfield" version = "0.4.0" @@ -3804,13 +4591,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" [[package]] -name = "parking" -version = "2.2.1" +name = "pango" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] [[package]] -name = "parking_lot" +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" @@ -3869,7 +4681,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -3910,6 +4722,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.12.1" @@ -4140,6 +4962,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -4156,6 +4984,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -4165,6 +5012,30 @@ dependencies = [ "toml_edit 0.25.13+spec-1.1.0", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4627,6 +5498,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -4642,6 +5527,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -4706,6 +5641,41 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4834,6 +5804,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.3", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4978,6 +5967,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4991,8 +5989,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5134,6 +6143,32 @@ dependencies = [ "serde_core", ] +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "spin" version = "0.9.9" @@ -5216,6 +6251,30 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strum" version = "0.27.2" @@ -5361,6 +6420,16 @@ dependencies = [ "zeno", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5425,6 +6494,19 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + [[package]] name = "taffy" version = "0.13.0" @@ -5449,6 +6531,23 @@ dependencies = [ "objc", ] +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -5473,6 +6572,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -5577,6 +6685,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5585,6 +6694,25 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -5705,6 +6833,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -5882,6 +7032,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5948,6 +7104,41 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5966,7 +7157,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -5987,6 +7178,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6069,6 +7266,18 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -6247,6 +7456,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-core 0.61.2", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6481,7 +7791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ "windows-core 0.57.0", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -6549,7 +7859,7 @@ dependencies = [ "windows-implement 0.57.0", "windows-interface 0.57.0", "windows-result 0.1.2", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -6693,7 +8003,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -6732,13 +8042,31 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -6750,20 +8078,35 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -6793,18 +8136,36 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6817,30 +8178,63 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -6896,6 +8290,50 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wry" +version = "0.56.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375becb4aded9913f736443cf88000c6311478db69814cc06070465e4cc44c98" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + [[package]] name = "x11" version = "2.21.0" @@ -6906,6 +8344,47 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xcb" version = "1.7.1" @@ -6918,6 +8397,45 @@ dependencies = [ "x11", ] +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.1" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xml-rs" version = "0.8.29" @@ -7041,7 +8559,7 @@ version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 3.0.4", @@ -7127,17 +8645,39 @@ dependencies = [ "xcb", ] +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + [[package]] name = "zeddy" version = "0.1.0" dependencies = [ "anyhow", + "assets", "futures", "gpui", "gpui_platform", + "gtk", + "rusqlite", + "serde", + "serde_json", "tempfile", "theme", + "toml 0.9.12+spec-1.1.0", "ui", + "ureq", + "url", + "wry", "zeddy-herdr", "zeddy-plugin", "zeddy-plugin-host", @@ -7148,7 +8688,7 @@ dependencies = [ name = "zeddy-herdr" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", "tempfile", @@ -7365,7 +8905,7 @@ version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 3.0.4", diff --git a/Cargo.toml b/Cargo.toml index ce29fd60..16ceb21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ [workspace.package] version = "0.1.0" edition = "2024" -rust-version = "1.90" +rust-version = "1.95" # Zed's `ui` and `theme` are GPL-3.0-or-later and zeddy links them directly, so # zeddy is too. See docs/adr/0002. license = "GPL-3.0-or-later" @@ -38,15 +38,19 @@ repository = "https://github.com/rengwu/chartr-zeddy" # rather than looks like a rewrite of native. Only `zeddy` may name # `gpui_platform`: a plugin that linked a second window-server backend would # register a second application with the OS. -gpui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", default-features = false } +gpui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", default-features = false, features = ["x11"] } # `font-kit` is NOT in gpui_platform's default features, and without it macOS # falls back to a text system that rasterises nothing: a window that paints # every quad and icon correctly and shows not one glyph, with the explanation # behind a `log::warn!` that an app with no logger never sees. It is enabled -# here on purpose, and `fonts::available` checks the result at startup. -gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", features = ["font-kit"] } +# here on purpose, and `fonts::text_renders` checks the result at startup. +# Wry's in-window Linux child webview is an X11 surface. Selecting the same +# GPUI backend makes both native and web panes work on X11 and XWayland instead +# of allowing GPUI to pick Wayland and rejecting web panes at runtime. +gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", features = ["font-kit", "x11"] } ui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } theme = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +zed_assets = { package = "assets", git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } # --- the terminal -------------------------------------------------------- # # Zed's fork of alacritty's VT core. Taking the same parser Zed's own terminal @@ -64,12 +68,16 @@ futures = "0.3" # the entire wire format, so this is the entire wire dependency. serde = { version = "1", features = ["derive"] } serde_json = "1" +rusqlite = { version = "0.32", features = ["bundled"] } base64 = "0.22" toml = "0.9" # The whole of the native plugin loader. There is no renderer, RPC transport, # or display-list replay between a plugin's view and zeddy's element tree. libloading = "0.8" tempfile = "3" +url = "2" +wry = "0.56.1" +ureq = "3" # The dev profile is the build whose window you actually drag. GPUI and the VT # core are both unusably slow at opt-level 0, and neither is code we are diff --git a/README.md b/README.md index 02bef994..d1c10d74 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,132 @@ -# zeddy +# Chartr -A simple agent multiplexer. Sessions live in a backend that outlives the -window; the window shows them in a sidebar or in a tab strip, and plugins add -panes beside them. +Chartr is a multi-space terminal and plugin workspace built on Zed's GPUI, +component, theme, action, and pane conventions. This rewrite keeps its data +isolated under the `chartr-zeddy` namespace. ```sh -sh vendor/herdr/fetch.sh # once per checkout, and whenever the pin moves +sh vendor/herdr/fetch.sh cargo run -p zeddy ``` -## What it is - -Open zeddy in a directory and it shows the sessions already running there, -adopting them rather than restarting them. `+` starts another. Quitting leaves -them running; the next launch picks them up where they were. - -Sessions are **agents**, not just shells — the backend already knows what a -pane is running, so a session carries its agent's name and status without zeddy -inspecting a process tree. - -## Two modes - -The same list, in the two places a list of sessions wants to be: - -- **Sidebar** — a vertical list down the left. Room for a title, the agent - under it, and a close button that is not fighting the title for space. The - mode for many long-lived sessions. -- **Tabs** — a horizontal strip across the top. Denser per session, familiar, - and no room for a second line. The mode for a handful you are switching - between quickly. - -Both are one enum and one branch in `render`. Toggling never touches a session, -because nothing below the chrome knows which mode is showing. - -## Layout - -```text -crates/zeddy/ the window, and nothing a lower crate could own -crates/zeddy-herdr/ the only code that knows herdr exists -crates/zeddy-vt/ the only code that knows a VT parser exists -crates/zeddy-plugin/ the contract a plugin is written against -crates/zeddy-plugin-host/ the only code that loads foreign code -plugins/ one example per tier; never installed automatically -vendor/herdr/ the pinned backend executable and its fetch script -docs/adr/ the decisions that would otherwise be re-litigated -``` - -Each crate is a boundary rather than a bag of helpers. Swapping the VT parser -is a change to one file; so is swapping the backend. - -## The backend is invisible - -zeddy runs a **private** herdr: its own socket, its own XDG directories, its own -session name, under `~/.local/state/zeddy/herdr`. It does not discover, attach -to, stop, upgrade, or write the herdr you run yourself, and a `HERDR_SOCKET_PATH` -inherited from your shell cannot reach it — every herdr process zeddy launches is -placed in that namespace explicitly. - -There is no backend administration surface. Starting and adopting are one call, -because the question zeddy acts on is not "is it running" but "can I talk to -it", and that is a `ping`. - -The executable is resolved by path, beside zeddy's own, never through `PATH`: a -herdr you installed for yourself is yours, and picking it up would make zeddy's -backend version depend on the machine. `crates/zeddy/build.rs` copies the -vendored one into place and fails the build if it is not there. +The supported desktop targets are macOS and Linux under X11 or XWayland. +Windows is deferred because Herdr currently uses Unix-domain sockets. Wry's +in-window Linux child webviews require X11, so Chartr selects the same GPUI +backend instead of exposing web panes that fail only on Wayland. + +## Spaces, panes, and items + +One window owns ordered spaces and one active space, following Zed's +`MultiWorkspace` responsibility. The permanent **Ad-hoc sessions** space is +folderless and starts sessions in the home directory (or its configured +replacement). Folder spaces are canonical-path identities with independent +recursive pane trees. + +Every terminal or plugin instance is one item owned by exactly one pane in one +space. Tabs never appear in several spaces. New sessions enter the active pane +of the selected space. A terminal item is non-cloneable; closing it terminates +its Herdr session. A plugin may opt into multiple instances, modifier cloning, +restoration, and explicit binding to one terminal session. + +Panes support nested horizontal and vertical splits, divider resizing, +directional focus, joining, zooming, tab reordering, movement, and edge-drop +splitting. The command palette provides keyboard alternatives for pane +operations. `Cmd+W` on macOS and `Ctrl+W` on Linux closes the active item; +operations that terminate multiple live sessions confirm with an exact count. + +Sidebar and tabbed modes are projections over that same model. Sidebar mode can +show all spaces or only the active space and groups each pane's items. Tabbed +mode shows one space and uses Zed tabs, including close controls for plugin +items. Switching presentation never reparents or recreates an item. + +## Settings and persistence + +Settings is presented inside the main window, retaining the spaces sidebar. +The implemented pages are General, Appearance, Terminal, Hotkeys, and Plugins. +Changes are written atomically; hotkeys are semantic GPUI actions with conflict +detection. Chartr Dark is the fixed default, with Chartr Light and system theme +pairs available. IBM Plex Sans and the bundled IBM Plex Mono are configurable +defaults. + +User-editable data remains text: + +- `$XDG_CONFIG_HOME/chartr-zeddy/settings.toml` +- `$XDG_CONFIG_HOME/chartr-zeddy/keymap.toml` +- `$XDG_CONFIG_HOME/chartr-zeddy/spaces.toml` + +Application-owned window, chrome, pane, selection, and restorable-item state is +versioned SQLite under `$XDG_STATE_HOME/chartr-zeddy/state.sqlite`. No existing +Go Chartr or Chartr-rs configuration is imported automatically. + +Normal app exit detaches sessions. An optional setting terminates them instead. +The private Herdr runtime uses an exact socket under +`$XDG_CONFIG_HOME/chartr-zeddy/herdr`; inherited Herdr selectors are cleared so +Chartr cannot attach to a user's standalone daemon. Broken streams become +item-local recovery states, and unexpected daemon death receives one clean +restart before entering a stable crash-loop state with Retry. ## Plugins -Both tiers contribute the same thing — a pane zeddy can show in the sidebar or -as a tab — and nothing above the plugin host asks which tier a pane came from. -A plugin is a directory with a `zeddy-plugin.toml` in it under -`~/.local/share/zeddy/plugins//`. +Plugins are directories under +`$XDG_DATA_HOME/chartr-zeddy/plugins//` containing +`zeddy-plugin.toml`. -**Native** (`kind = "native"`) is a `cdylib`. Its view is an ordinary GPUI -`AnyView` mounted directly in zeddy's element tree, so scrolling, resizing, -focus, input, and painting use exactly the same frame path as a built-in. -There is no webview, Wasm runtime, synthetic window, display-list replay, or UI -RPC layer. The whole authoring contract is one trait, one macro, and a manifest: +Native plugins are fully trusted Rust dynamic libraries. They receive a stable +`InstanceContext` and return ordinary GPUI views: ```rust -use zeddy_plugin::{Host, PaneKey, Plugin, Registrar, gpui, register}; - -struct StarMap; - -impl Plugin for StarMap { - const ID: &'static str = "com.example.starmap"; - fn new(_: Host, _: &mut gpui::App) -> Self { Self } - fn activate(&mut self, r: &mut Registrar, _: &mut gpui::App) { r.add_pane("map", "Star map"); } - fn view(&mut self, _: &PaneKey, _: &mut gpui::Window, cx: &mut gpui::App) -> gpui::AnyView { - cx.new(|_| MapView::default()).into() - } -} - -register!(StarMap); +fn view( + &mut self, + pane: &PaneKey, + context: &InstanceContext, + window: &mut gpui::Window, + cx: &mut gpui::App, +) -> gpui::AnyView; ``` -That openness is also the trust model. A native plugin may use raw GPUI, any -compatible crate, the filesystem, processes, and the network; installing one is -installing native code, and no sandbox is claimed. - -**Web** (`kind = "web"`) is a manifest and an entry document. No Rust, no -toolchain, no ABI to match — anyone who has written a web page can write one, -and it is sandboxed. The trade is that its pane is composited rather than -painted on zeddy's frame path, so it is a frame behind the terminal beside it. +Native plugins may advertise one lazy Settings contribution through their +registrar. Libraries remain mapped until process exit so disabling one cannot +invalidate a live Rust vtable. -The two tiers exist because "anyone can author one" and "fast enough to paint a -star map at 120fps" are different requirements, and one runtime cannot honestly -be both. See [ADR 0003](docs/adr/0003-two-plugin-tiers.md). +Web plugins are real Wry panes with local assets and a restrictive CSP. Their +manifest declares project-file, domain-scoped network, process, and optional +bound-session host actions. Safe filesystem paths are canonicalized beneath the +owning project, while folderless plugins receive only plugin data. Unrestricted +filesystem access is an explicit per-plugin grant; there is no global unsafe +switch. Revoking a grant or disabling a plugin destroys its live brokers and +views immediately. A web plugin may name a lazy `settings_entry` document. -`plugins/hello` is a complete native plugin; `plugins/clock` is a complete web -one. Neither is seeded or installed automatically. +`plugins/hello` and `plugins/clock` are complete native and web examples. They +are development references and are not installed automatically. -## Testing +## Repository boundaries -```sh -cargo test --workspace +```text +crates/zeddy/ window, spaces, panes, settings, persistence, UI +crates/zeddy-herdr/ private Herdr protocol and lifecycle +crates/zeddy-vt/ terminal parser boundary +crates/zeddy-plugin/ native and manifest authoring contract +crates/zeddy-plugin-host/ discovery, loading, and web filesystem broker +plugins/ one complete example per plugin tier +vendor/herdr/ pinned sidecar fetch and licence +docs/adr/ architectural decisions +.plan/maps/ durable product specification ``` -Hermetic: no test contacts a herdr daemon. The pieces that can only be checked -against a real one are ignored by default and use the vendored executable: +## Verification ```sh -cargo test -p zeddy --test live_session -- --ignored --nocapture +cargo fmt --all --check +cargo test --workspace --locked --no-fail-fast +cargo check --manifest-path plugins/hello/Cargo.toml --locked +cargo test -p zeddy --test live_session -- --ignored --nocapture --test-threads=1 ``` -Run that one when the herdr pin moves. The frame stream rides herdr's command -line, which carries no compatibility promise, and it is the one coupling no -unit test can see break. +The last command launches and hard-crashes the real pinned private Herdr. The +macOS/Linux build matrix and release acceptance checklist live in +`.github/workflows/ci.yml` and `docs/acceptance.md`. ## Licence -GPL-3.0-or-later. zeddy links Zed's `ui` and `theme` crates directly, and those -are GPL-3.0-or-later; see [ADR 0002](docs/adr/0002-the-zed-layer.md). +GPL-3.0-or-later. Chartr links Zed's `ui` and `theme` crates directly; see +[ADR 0002](docs/adr/0002-the-zed-layer.md). diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs index e6be13df..e042ce6d 100644 --- a/crates/zeddy-herdr/src/control.rs +++ b/crates/zeddy-herdr/src/control.rs @@ -100,6 +100,87 @@ impl Client { } } + /// Whether anything is accepting connections at this private socket. + /// + /// Supervision deliberately asks the operating system rather than pinging + /// the daemon: a crashed daemon cannot answer a health check, while both a + /// removed socket and a stale one refuse this connect. + pub fn answers(&self) -> bool { + UnixStream::connect(self.namespace.socket()).is_ok() + } + + /// Start exactly one clean replacement for a daemon that has already died. + /// + /// This clears herdr's saved runtime shape before spawning. It does not + /// wait or retry; [`reconnect`](Self::reconnect) is intentionally the only + /// wait path so recovery cannot turn into a hidden spawn loop. + pub fn restart(&self) -> Result<()> { + self.namespace.prepare()?; + if self.answers() { + self.stop_daemon()?; + } + self.clear_saved_shape()?; + self.spawn_daemon() + } + + /// Ask this exact private daemon to stop and wait for its socket to close. + pub fn stop_daemon(&self) -> Result<()> { + let mut command = Command::new(self.sidecar.path()); + command + .args(["server", "stop"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + apply(&mut command, &self.namespace); + let status = command.status()?; + if !status.success() && self.answers() { + return Err(Error::Backend { + method: "server stop", + message: format!("herdr exited with {status}"), + }); + } + let deadline = Instant::now() + Duration::from_secs(5); + while self.answers() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(25)); + } + if self.answers() { + return Err(Error::Backend { + method: "server stop", + message: "the private socket is still accepting connections".to_owned(), + }); + } + Ok(()) + } + + /// Wait for an already-started daemon to answer, without starting another. + pub fn reconnect(&self, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + let mut backoff = Duration::from_millis(25); + loop { + match self.handshake() { + Ok(()) => return Ok(()), + Err(err) if Instant::now() + backoff >= deadline => return Err(err), + Err(_) => { + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_millis(200)); + } + } + } + } + + /// Remove only the private daemon's saved workspace shape. + pub fn clear_saved_shape(&self) -> Result<()> { + let shape = self.namespace.saved_shape(); + match std::fs::remove_file(&shape) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(Error::Transport(std::io::Error::new( + error.kind(), + format!("cannot clear {}: {error}", shape.display()), + ))), + } + } + /// `ping`, checked against the version this client was written for. /// /// A version mismatch is an error and not a warning. The frame stream rides @@ -161,9 +242,18 @@ impl Client { if let Some(existing) = self.workspace_at(cwd)? { return Ok(existing); } + Ok(self.create_workspace(cwd, label)?.workspace) + } + + /// Create a workspace and return its root session. + /// + /// herdr creates a workspace and its first pane as one operation. Callers + /// that are implementing "new session" need that pane rather than only its + /// workspace id, or they would create a second pane and lose the first. + pub fn create_workspace(&self, cwd: &Path, label: Option<&str>) -> Result { let params = WorkspaceCreateParams { cwd: &cwd.to_string_lossy(), label }; let created: Created = self.call("workspace.create", ¶ms)?; - Ok(Session::from(created.root_pane).workspace) + Ok(created.root_pane.into()) } /// Start one more session in a workspace that is already open. @@ -312,4 +402,25 @@ mod tests { let err = client.handshake().expect_err("nothing is listening"); assert!(err.to_string().contains(&namespace.socket().display().to_string()), "{err}"); } + + #[test] + fn a_clean_restart_removes_only_the_saved_shape() { + let tmp = tempfile::tempdir().expect("tempdir"); + let namespace = Namespace::rooted(tmp.path().join("private")); + namespace.prepare().expect("prepare"); + let shape = namespace.saved_shape(); + std::fs::create_dir_all(shape.parent().expect("shape directory")).expect("directory"); + std::fs::write(&shape, b"stale shape").expect("shape"); + let neighbor = shape.parent().expect("shape directory").join("config.toml"); + std::fs::write(&neighbor, b"managed config").expect("config"); + let herdr = tmp.path().join("herdr"); + std::fs::write(&herdr, b"#!/bin/sh\n").expect("sidecar"); + let client = Client::new(Sidecar::at(&herdr).expect("sidecar"), namespace); + + client.clear_saved_shape().expect("clear shape"); + + assert!(!shape.exists()); + assert_eq!(std::fs::read(&neighbor).expect("config remains"), b"managed config"); + client.clear_saved_shape().expect("already absent is harmless"); + } } diff --git a/crates/zeddy-herdr/src/namespace.rs b/crates/zeddy-herdr/src/namespace.rs index 2ac1fde9..6d289423 100644 --- a/crates/zeddy-herdr/src/namespace.rs +++ b/crates/zeddy-herdr/src/namespace.rs @@ -12,23 +12,24 @@ use std::{ffi::OsString, path::PathBuf}; /// The private locations and environment of zeddy's own herdr. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Namespace { + /// Herdr's own directory (`/herdr`). root: PathBuf, - session: String, } impl Namespace { - /// The namespace zeddy uses in production: `/zeddy/herdr`. + /// The namespace Chartr-zeddy uses in production: + /// `/chartr-zeddy/herdr`. /// - /// State rather than config or cache, because what lives here is neither - /// something an operator edits nor something safe to evict mid-session. + /// Herdr itself resolves all private runtime paths relative to its config + /// home, so this follows the proven Chartr-rs namespace shape exactly. pub fn private() -> Self { - Self::rooted(state_home().join("zeddy").join("herdr")) + Self::rooted(config_home().join("chartr-zeddy").join("herdr")) } /// A namespace under an arbitrary root. Tests use this to get a whole /// private backend in a scratch directory; nothing else should need it. pub fn rooted(root: impl Into) -> Self { - Self { root: root.into(), session: "zeddy".to_owned() } + Self { root: root.into() } } /// The Unix socket the control plane connects to. @@ -41,22 +42,18 @@ impl Namespace { self.root.join("daemon.log") } - /// herdr's named session inside this namespace. - pub fn session(&self) -> &str { - &self.session + /// Herdr's persisted workspace/tab/pane shape. + /// + /// A replacement after a crash must start without this file. The PTYs that + /// were represented by the saved shape died with the daemon; letting herdr + /// recreate it would present fresh shells as if they were the old work. + pub fn saved_shape(&self) -> PathBuf { + self.root.join("session.json") } /// Create every directory herdr will expect to write into. pub fn prepare(&self) -> std::io::Result<()> { - for dir in [ - &self.root, - &self.xdg("config"), - &self.xdg("state"), - &self.xdg("data"), - &self.xdg("cache"), - ] { - std::fs::create_dir_all(dir)?; - } + std::fs::create_dir_all(&self.root)?; Ok(()) } @@ -71,12 +68,9 @@ impl Namespace { let set = |k: &str, v: OsString| (OsString::from(k), Some(v)); let clear = |k: &str| (OsString::from(k), None); vec![ - set("XDG_CONFIG_HOME", self.xdg("config").into()), - set("XDG_STATE_HOME", self.xdg("state").into()), - set("XDG_DATA_HOME", self.xdg("data").into()), - set("XDG_CACHE_HOME", self.xdg("cache").into()), + set("XDG_CONFIG_HOME", self.root.parent().unwrap_or(&self.root).as_os_str().to_owned()), set("HERDR_SOCKET_PATH", self.socket().into()), - set("HERDR_SESSION", self.session.clone().into()), + clear("HERDR_SESSION"), clear("HERDR_CLIENT_SOCKET_PATH"), clear("HERDR_CONFIG_PATH"), clear("HERDR_ENV"), @@ -85,21 +79,17 @@ impl Namespace { clear("HERDR_PANE_ID"), ] } - - fn xdg(&self, which: &str) -> PathBuf { - self.root.join("xdg").join(which) - } } -/// `$XDG_STATE_HOME`, or the platform default when it is unset or relative. -fn state_home() -> PathBuf { - if let Some(dir) = std::env::var_os("XDG_STATE_HOME") { +/// `$XDG_CONFIG_HOME`, or the platform default when it is unset or relative. +fn config_home() -> PathBuf { + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { let dir = PathBuf::from(dir); if dir.is_absolute() { return dir; } } - home().join(".local").join("state") + home().join(".config") } fn home() -> PathBuf { @@ -113,7 +103,7 @@ mod tests { #[test] fn every_private_path_stays_under_the_root() { let ns = Namespace::rooted("/scratch/root"); - for path in [ns.socket(), ns.log(), ns.xdg("config"), ns.xdg("state")] { + for path in [ns.socket(), ns.log(), ns.saved_shape()] { assert!(path.starts_with("/scratch/root"), "{path:?} escaped the private root"); } } @@ -122,7 +112,13 @@ mod tests { fn inherited_herdr_context_is_cleared_not_merely_overridden() { let ns = Namespace::rooted("/scratch/root"); let env = ns.env(); - for key in ["HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_CONFIG_PATH"] { + for key in [ + "HERDR_SESSION", + "HERDR_PANE_ID", + "HERDR_TAB_ID", + "HERDR_WORKSPACE_ID", + "HERDR_CONFIG_PATH", + ] { let entry = env.iter().find(|(k, _)| k == key).expect("key is in the namespace env"); assert!(entry.1.is_none(), "{key} must be removed, not set"); } @@ -143,6 +139,12 @@ mod tests { let ns = Namespace::rooted(tmp.path().join("ns")); ns.prepare().expect("prepare"); assert!(ns.socket().parent().expect("root").is_dir()); - assert!(ns.xdg("config").is_dir()); + let config = ns + .env() + .into_iter() + .find(|(key, _)| key == "XDG_CONFIG_HOME") + .and_then(|(_, value)| value) + .expect("config home"); + assert_eq!(PathBuf::from(config), ns.root.parent().expect("config home")); } } diff --git a/crates/zeddy-plugin-host/src/lib.rs b/crates/zeddy-plugin-host/src/lib.rs index 98ff087e..7a6a6c4b 100644 --- a/crates/zeddy-plugin-host/src/lib.rs +++ b/crates/zeddy-plugin-host/src/lib.rs @@ -28,7 +28,7 @@ use std::{ use zeddy_plugin::{ Entry, Host, PaneKey, PaneSpec, PluginObject, Registrar, - manifest::{Invalid, Kind, Manifest}, + manifest::{Capabilities, Invalid, Kind, Manifest, Permissions, ProjectAccess}, }; /// One plugin, loaded and activated. @@ -36,6 +36,7 @@ pub struct Loaded { pub manifest: Manifest, pub dir: PathBuf, pub panes: Vec, + pub has_settings: bool, tier: Tier, } @@ -43,7 +44,12 @@ pub struct Loaded { /// between "native" and "web" is still visible. enum Tier { Native(Native), - Web { entry: PathBuf }, + Web { entry: PathBuf, settings_entry: Option }, +} + +pub enum SettingsSource { + Native(gpui::AnyView), + Web(PathBuf), } /// A loaded native library and the object it produced. @@ -70,6 +76,14 @@ impl Loaded { self.manifest.kind } + pub fn capabilities(&self) -> &Capabilities { + &self.manifest.capabilities + } + + pub fn permissions(&self) -> &Permissions { + &self.manifest.permissions + } + /// How to build one of this plugin's panes. /// /// `None` for a pane this plugin did not declare — which is what a stale @@ -80,9 +94,23 @@ impl Loaded { } Some(match &mut self.tier { Tier::Native(native) => PaneSource::Native(native.plugin.as_mut()), - Tier::Web { entry } => PaneSource::Web(entry.as_path()), + Tier::Web { entry, .. } => PaneSource::Web(entry.as_path()), }) } + + pub fn settings( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> Option { + if !self.has_settings { + return None; + } + match &mut self.tier { + Tier::Native(native) => native.plugin.settings(window, cx).map(SettingsSource::Native), + Tier::Web { settings_entry, .. } => settings_entry.clone().map(SettingsSource::Web), + } + } } /// A plugin directory that could not be loaded, kept so Settings can say why @@ -93,12 +121,19 @@ pub struct Rejected { pub why: String, } +#[derive(Debug, Clone)] +pub struct Disabled { + pub manifest: Manifest, + pub dir: PathBuf, +} + /// Everything found in one scan. #[derive(Default)] pub struct Catalog { /// Loaded plugins, by id. A `BTreeMap` so the sidebar's order is the same /// on every launch rather than the order the filesystem happened to answer. pub loaded: BTreeMap, + pub disabled: BTreeMap, pub rejected: Vec, } @@ -111,8 +146,141 @@ impl Catalog { pub fn get_mut(&mut self, plugin: &str) -> Option<&mut Loaded> { self.loaded.get_mut(plugin) } + + pub fn get(&self, plugin: &str) -> Option<&Loaded> { + self.loaded.get(plugin) + } + + pub fn disable(&mut self, plugin: &str) -> bool { + let Some(loaded) = self.loaded.remove(plugin) else { + return false; + }; + self.disabled + .insert(plugin.to_owned(), Disabled { manifest: loaded.manifest, dir: loaded.dir }); + true + } + + pub fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) { + self.loaded.retain(|id, _| keep(id)); + } + + pub fn enable( + &mut self, + paths: &Paths, + plugin: &str, + cx: &mut gpui::App, + ) -> Result<(), LoadError> { + let Some(disabled) = self.disabled.remove(plugin) else { + return Ok(()); + }; + match load_one(&disabled.dir, paths, cx) { + Ok(loaded) => { + self.loaded.insert(plugin.to_owned(), loaded); + Ok(()) + } + Err(error) => { + self.disabled.insert(plugin.to_owned(), disabled); + Err(error) + } + } + } +} + +/// Filesystem authority for one web-plugin instance. +#[derive(Debug, Clone)] +pub struct FileBroker { + project: Option, + data: PathBuf, + access: ProjectAccess, + unsafe_filesystem: bool, +} + +impl FileBroker { + pub fn new( + project: Option, + data: PathBuf, + access: ProjectAccess, + unsafe_filesystem: bool, + ) -> Self { + Self { project, data, access, unsafe_filesystem } + } + + pub fn project_path(&self, requested: &Path, write: bool) -> Result { + if self.unsafe_filesystem { + return Ok(if requested.is_absolute() { + requested.to_owned() + } else if let Some(project) = &self.project { + project.join(requested) + } else { + self.data.join(requested) + }); + } + match (self.access, write) { + (ProjectAccess::None, _) | (ProjectAccess::Read, true) => { + return Err(BrokerError::Denied); + } + _ => {} + } + let root = self.project.as_ref().ok_or(BrokerError::Folderless)?; + contained(root, requested, write) + } + + pub fn data_path(&self, requested: &Path, write: bool) -> Result { + contained(&self.data, requested, write) + } +} + +fn contained(root: &Path, requested: &Path, write: bool) -> Result { + if requested.is_absolute() + || requested.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(BrokerError::Escape); + } + let root = root.canonicalize().map_err(BrokerError::Io)?; + let candidate = root.join(requested); + let resolved = if write && !candidate.exists() { + let parent = candidate.parent().ok_or(BrokerError::Escape)?; + let parent = parent.canonicalize().map_err(BrokerError::Io)?; + parent.join(candidate.file_name().ok_or(BrokerError::Escape)?) + } else { + candidate.canonicalize().map_err(BrokerError::Io)? + }; + if !resolved.starts_with(&root) { + return Err(BrokerError::Escape); + } + Ok(resolved) +} + +#[derive(Debug)] +pub enum BrokerError { + Denied, + Folderless, + Escape, + Io(std::io::Error), +} + +impl std::fmt::Display for BrokerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Denied => write!(formatter, "the plugin did not declare this project access"), + Self::Folderless => { + write!(formatter, "safe mode exposes no project filesystem in the folderless space") + } + Self::Escape => write!(formatter, "the requested path escapes the allowed root"), + Self::Io(error) => write!(formatter, "resolving the requested path: {error}"), + } + } } +impl std::error::Error for BrokerError {} + /// Where plugins and their data live. #[derive(Debug, Clone)] pub struct Paths { @@ -134,6 +302,14 @@ impl Paths { /// One bad plugin is recorded and skipped, never fatal: a plugin that fails to /// load must not be able to stop zeddy from opening. pub fn load_all(paths: &Paths, cx: &mut gpui::App) -> Catalog { + load_all_where(paths, |_| true, cx) +} + +pub fn load_all_where( + paths: &Paths, + mut enabled: impl FnMut(&str) -> bool, + cx: &mut gpui::App, +) -> Catalog { let mut catalog = Catalog::default(); let Ok(entries) = std::fs::read_dir(&paths.installed) else { return catalog; @@ -144,6 +320,12 @@ pub fn load_all(paths: &Paths, cx: &mut gpui::App) -> Catalog { dirs.sort(); for dir in dirs { + if let Ok(manifest) = Manifest::read(&dir) + && !enabled(&manifest.id) + { + catalog.disabled.insert(manifest.id.clone(), Disabled { manifest, dir }); + continue; + } match load_one(&dir, paths, cx) { Ok(plugin) => { catalog.loaded.insert(plugin.manifest.id.clone(), plugin); @@ -200,7 +382,7 @@ fn load_one(dir: &Path, paths: &Paths, cx: &mut gpui::App) -> Result { let filename = manifest .library_filename() @@ -209,8 +391,8 @@ fn load_one(dir: &Path, paths: &Paths, cx: &mut gpui::App) -> Result { let entry = dir.join(manifest.entry.as_deref().unwrap_or("index.html")); @@ -224,11 +406,18 @@ fn load_one(dir: &Path, paths: &Paths, cx: &mut gpui::App) -> Result Result<(Native, Vec), LoadError> { +) -> Result<(Native, Vec, bool), LoadError> { // SAFETY: loading a library runs its initialisers, which is arbitrary // native code. That is the documented trust model of the native tier — the // manifest's `native_abi` has already been checked to match this build, and @@ -262,8 +451,9 @@ fn open_native( let mut registrar = Registrar::new(id); plugin.activate(&mut registrar, cx); let panes = registrar.panes().to_vec(); + let has_settings = registrar.has_settings(); - Ok((Native { plugin, _library: library }, panes)) + Ok((Native { plugin, _library: library }, panes, has_settings)) } #[cfg(test)] @@ -287,7 +477,7 @@ mod tests { std::fs::write( dir.join("zeddy-plugin.toml"), format!( - "manifest_version = 1\nid = \"{id}\"\nname = \"Notes\"\n\ + "manifest_version = 2\nid = \"{id}\"\nname = \"Notes\"\n\ version = \"0.1.0\"\nkind = \"web\"\nentry = \"index.html\"\n" ), ) @@ -307,6 +497,40 @@ mod tests { assert_eq!(catalog.panes()[0].title, "Notes"); } + #[gpui::test] + fn a_web_settings_document_is_validated_but_not_constructed_during_discovery( + cx: &mut gpui::TestAppContext, + ) { + let (_tmp, paths) = paths(); + let dir = write_web(&paths, "com.example.notes", "com.example.notes"); + let manifest = std::fs::read_to_string(dir.join("zeddy-plugin.toml")).unwrap(); + std::fs::write( + dir.join("zeddy-plugin.toml"), + format!("{manifest}settings_entry = \"settings.html\"\n"), + ) + .unwrap(); + std::fs::write(dir.join("settings.html"), "

settings

").unwrap(); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert!(catalog.get("com.example.notes").unwrap().has_settings); + } + + #[gpui::test] + fn a_declared_missing_web_settings_document_rejects_the_plugin(cx: &mut gpui::TestAppContext) { + let (_tmp, paths) = paths(); + let dir = write_web(&paths, "com.example.notes", "com.example.notes"); + let manifest = std::fs::read_to_string(dir.join("zeddy-plugin.toml")).unwrap(); + std::fs::write( + dir.join("zeddy-plugin.toml"), + format!("{manifest}settings_entry = \"missing.html\"\n"), + ) + .unwrap(); + + let catalog = cx.update(|cx| load_all(&paths, cx)); + assert!(catalog.loaded.is_empty()); + assert!(catalog.rejected[0].why.contains("missing.html")); + } + #[gpui::test] fn a_directory_whose_name_disagrees_with_the_manifest_is_rejected( cx: &mut gpui::TestAppContext, @@ -362,4 +586,61 @@ mod tests { let catalog = cx.update(|cx| load_all(&paths, cx)); assert!(catalog.loaded.is_empty() && catalog.rejected.is_empty()); } + + #[test] + fn safe_project_access_stays_beneath_the_canonical_root() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let data = temp.path().join("data"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + std::fs::write(project.join("readme.md"), "hello").unwrap(); + let broker = FileBroker::new(Some(project.clone()), data, ProjectAccess::ReadWrite, false); + + assert_eq!( + broker.project_path(Path::new("readme.md"), false).unwrap(), + project.canonicalize().unwrap().join("readme.md") + ); + assert!(matches!( + broker.project_path(Path::new("../outside"), true), + Err(BrokerError::Escape) + )); + } + + #[test] + fn folderless_safe_plugins_receive_only_their_data_root() { + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("data"); + std::fs::create_dir_all(&data).unwrap(); + let broker = FileBroker::new(None, data.clone(), ProjectAccess::ReadWrite, false); + + assert!(matches!( + broker.project_path(Path::new("anything"), false), + Err(BrokerError::Folderless) + )); + assert_eq!( + broker.data_path(Path::new("state.json"), true).unwrap(), + data.canonicalize().unwrap().join("state.json") + ); + } + + #[cfg(unix)] + #[test] + fn symlinks_cannot_escape_a_safe_project_root() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let outside = temp.path().join("outside"); + let data = temp.path().join("data"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + symlink(&outside, project.join("escape")).unwrap(); + let broker = FileBroker::new(Some(project), data, ProjectAccess::ReadWrite, false); + + assert!(matches!( + broker.project_path(Path::new("escape/file.txt"), true), + Err(BrokerError::Escape) + )); + } } diff --git a/crates/zeddy-plugin/src/lib.rs b/crates/zeddy-plugin/src/lib.rs index 52390271..0c983714 100644 --- a/crates/zeddy-plugin/src/lib.rs +++ b/crates/zeddy-plugin/src/lib.rs @@ -29,7 +29,7 @@ //! registrar.add_pane("map", "Star map"); //! } //! -//! fn view(&mut self, _: &PaneKey, _: &mut gpui::Window, cx: &mut gpui::App) -> gpui::AnyView { +//! fn view(&mut self, _: &PaneKey, _: &InstanceContext, _: &mut gpui::Window, cx: &mut gpui::App) -> gpui::AnyView { //! cx.new(|_| MapView::default()).into() //! } //! } @@ -53,7 +53,7 @@ pub mod manifest; pub use gpui; -pub use manifest::{Kind, Manifest}; +pub use manifest::{Capabilities, Kind, Manifest, Multiplicity, Permissions, ProjectAccess}; use std::path::PathBuf; @@ -80,6 +80,14 @@ pub struct PaneSpec { pub title: String, } +/// Stable ownership handed to one concrete pane instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceContext { + pub space: String, + pub project_dir: Option, + pub bound_session: Option, +} + /// What a plugin declares during [`Plugin::activate`]. /// /// Declaring is separate from building. Activation runs once, and zeddy calls @@ -89,11 +97,12 @@ pub struct PaneSpec { pub struct Registrar { plugin: String, panes: Vec, + settings: bool, } impl Registrar { pub fn new(plugin: impl Into) -> Self { - Self { plugin: plugin.into(), panes: Vec::new() } + Self { plugin: plugin.into(), panes: Vec::new(), settings: false } } /// Contribute a pane. `key` is this plugin's own name for it. @@ -106,6 +115,16 @@ impl Registrar { pub fn panes(&self) -> &[PaneSpec] { &self.panes } + + /// Advertise one user-global settings contribution. Its view remains lazy. + pub fn add_settings(&mut self) -> &mut Self { + self.settings = true; + self + } + + pub fn has_settings(&self) -> bool { + self.settings + } } /// What zeddy hands a plugin at construction. @@ -138,9 +157,19 @@ pub trait Plugin: Sized + 'static { fn view( &mut self, pane: &PaneKey, + context: &InstanceContext, window: &mut gpui::Window, cx: &mut gpui::App, ) -> gpui::AnyView; + + /// Build this plugin's user-global Settings contribution on first open. + fn settings( + &mut self, + _window: &mut gpui::Window, + _cx: &mut gpui::App, + ) -> Option { + None + } } /// The object-safe face of [`Plugin`], which is what crosses the library @@ -151,9 +180,11 @@ pub trait PluginObject { fn view( &mut self, pane: &PaneKey, + context: &InstanceContext, window: &mut gpui::Window, cx: &mut gpui::App, ) -> gpui::AnyView; + fn settings(&mut self, window: &mut gpui::Window, cx: &mut gpui::App) -> Option; } impl PluginObject for P { @@ -168,10 +199,15 @@ impl PluginObject for P { fn view( &mut self, pane: &PaneKey, + context: &InstanceContext, window: &mut gpui::Window, cx: &mut gpui::App, ) -> gpui::AnyView { - Plugin::view(self, pane, window, cx) + Plugin::view(self, pane, context, window, cx) + } + + fn settings(&mut self, window: &mut gpui::Window, cx: &mut gpui::App) -> Option { + Plugin::settings(self, window, cx) } } @@ -228,4 +264,12 @@ mod tests { two.add_pane("main", "Two"); assert_ne!(one.panes()[0].key, two.panes()[0].key); } + + #[test] + fn settings_are_opt_in_and_remain_lazy() { + let mut registrar = Registrar::new("com.example.settings"); + assert!(!registrar.has_settings()); + registrar.add_settings(); + assert!(registrar.has_settings()); + } } diff --git a/crates/zeddy-plugin/src/manifest.rs b/crates/zeddy-plugin/src/manifest.rs index 5197e430..3c76b8c8 100644 --- a/crates/zeddy-plugin/src/manifest.rs +++ b/crates/zeddy-plugin/src/manifest.rs @@ -9,7 +9,7 @@ use std::path::Path; use serde::Deserialize; /// The manifest version this build reads. Bumped when a field changes meaning. -pub const MANIFEST_VERSION: u32 = 1; +pub const MANIFEST_VERSION: u32 = 2; /// The native ABI this build links. /// @@ -18,7 +18,50 @@ pub const MANIFEST_VERSION: u32 = 1; /// compatibility range and there is not going to be one: a mismatch is a /// vtable from a different compilation, and the failure mode is a crash rather /// than a wrong answer. -pub const NATIVE_ABI: u32 = 1; +pub const NATIVE_ABI: u32 = 2; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Multiplicity { + /// Reopening focuses the existing item in that owning space. + #[default] + PerSpace, + /// Each open request creates an independent instance. + Multiple, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectAccess { + #[default] + None, + Read, + ReadWrite, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct Permissions { + #[serde(default)] + pub project_files: ProjectAccess, + #[serde(default)] + pub network: Vec, + #[serde(default)] + pub process: bool, + #[serde(default)] + pub session: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct Capabilities { + #[serde(default)] + pub multiplicity: Multiplicity, + #[serde(default)] + pub cloneable: bool, + #[serde(default)] + pub restorable: bool, + #[serde(default)] + pub session_binding: bool, +} /// Which tier a plugin belongs to. /// @@ -50,6 +93,10 @@ pub struct Manifest { pub name: String, pub version: String, pub kind: Kind, + #[serde(default)] + pub capabilities: Capabilities, + #[serde(default)] + pub permissions: Permissions, /// Native only: the Cargo library stem. zeddy appends the platform's /// extension, so one manifest covers `.dylib`, `.so`, and `.dll`. #[serde(default)] @@ -60,6 +107,9 @@ pub struct Manifest { /// Web only: the entry document, relative to the plugin directory. #[serde(default)] pub entry: Option, + /// Web only: an optional document constructed lazily inside Settings. + #[serde(default)] + pub settings_entry: Option, } /// Why a manifest was refused. @@ -177,17 +227,17 @@ mod tests { } const NATIVE: &str = r#" - manifest_version = 1 + manifest_version = 2 id = "com.example.starmap" name = "Star map" version = "0.1.0" kind = "native" library = "starmap" - native_abi = 1 + native_abi = 2 "#; const WEB: &str = r#" - manifest_version = 1 + manifest_version = 2 id = "com.example.notes" name = "Notes" version = "0.1.0" @@ -201,15 +251,34 @@ mod tests { assert_eq!(parse(WEB).expect("web").kind, Kind::Web); } + #[test] + fn capabilities_and_permissions_are_explicit_and_default_safe() { + let defaults = parse(WEB).expect("web defaults"); + assert_eq!(defaults.capabilities.multiplicity, Multiplicity::PerSpace); + assert!(!defaults.capabilities.cloneable); + assert_eq!(defaults.permissions.project_files, ProjectAccess::None); + assert!(defaults.permissions.network.is_empty()); + + let declared = parse(&format!( + "{WEB}\n[capabilities]\nmultiplicity = 'multiple'\ncloneable = true\nrestorable = true\nsession_binding = true\n\ + [permissions]\nproject_files = 'read_write'\nnetwork = ['https://api.example.com']\nprocess = true\nsession = true\n" + )) + .expect("declared contract"); + assert_eq!(declared.capabilities.multiplicity, Multiplicity::Multiple); + assert!(declared.capabilities.cloneable && declared.capabilities.restorable); + assert_eq!(declared.permissions.project_files, ProjectAccess::ReadWrite); + assert!(declared.permissions.process && declared.permissions.session); + } + #[test] fn a_native_plugin_from_another_abi_is_refused() { - let wrong = NATIVE.replace("native_abi = 1", "native_abi = 2"); - assert_eq!(parse(&wrong), Err(Invalid::NativeAbi { found: Some(2) })); + let wrong = NATIVE.replace("native_abi = 2", "native_abi = 99"); + assert_eq!(parse(&wrong), Err(Invalid::NativeAbi { found: Some(99) })); } #[test] fn a_native_plugin_without_an_abi_is_refused_rather_than_assumed() { - let missing = NATIVE.replace("native_abi = 1", ""); + let missing = NATIVE.replace("native_abi = 2", ""); assert_eq!(parse(&missing), Err(Invalid::NativeAbi { found: None })); } diff --git a/crates/zeddy/Cargo.toml b/crates/zeddy/Cargo.toml index a1201765..46d878e3 100644 --- a/crates/zeddy/Cargo.toml +++ b/crates/zeddy/Cargo.toml @@ -18,9 +18,23 @@ gpui.workspace = true gpui_platform.workspace = true ui.workspace = true theme.workspace = true +zed_assets.workspace = true anyhow.workspace = true futures.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +tempfile.workspace = true +rusqlite.workspace = true +url.workspace = true +ureq.workspace = true + +[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] +wry.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/zeddy/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf b/crates/zeddy/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4254c37f20ba57e829a3315fdc91cda4e63589f5 GIT binary patch literal 173052 zcmd4434E2s)jxh_o|}!VH~V^Xm+X6TZ#J@$ge+tsKmq|-!j3G0ii(PgmMWrFRH~?` zsI*c=OciYmE>uuxMMb5STB^~~7OQP-Uu;p~=J!3%l6#Y&wf(>E=kpi8cb;dSSm}0Cr|su(hDyq`Ms4y zAv>F~=IgsBA_#U=&<&{fjM6@3yDvcx=K~2FCO+PsL>b>(qE5D!sGjWEp=tA<L`KB-#8sk zgTuE2948%^4b%b_pb97kx`AY%2WSL1-Ygwua5-(JWfH()*8vRx`}21WYq+_1jsy5x z3g7_Z0W(kla5w{;_XOSF4wuWww6R~Q{#*gq0GEaH=lE8D%f;c@<+AWI=Uoll?*)#> zARWg0$9T^H*gc{__`=--8~{E9rUDlM{GHta9UO>RywWrH9c&Ij%j z>|ggCk&keU?-D-4D_jVk+r98j_1c0kOMpaxW$yXFeSh}FTb zz2TcsfN$Pl8IT8b09*!^8_a9m&snxj1Xch%M%@JPn997y-?%Ru{e^W2ZZGCP?xTjw zaT)PIEzk_`H%@;(!1?n#^AGbj=U)uu=-_nu8>h+j#oxUVh<672tp<#=j4(_S$4>^B z=X!vR0L!HafZIj_+~(fx!E)IL;CKcfGEcC+!f}{ZZfmBK%VvPn;JPvDnEMPrGyObf z^E1n~@c`EWgTpg8EZ4CCPWvLjTV`@uj5?3jpSg|xW&A7YwkRV18j*7%ca=jTu}X&dYeW;yDVqAGjBI7`O+x z3)rE*?}WP@cmVjW;dUYHkANqE#{f>_Y5m=BpTPTf4Ht&646cvSF8hU!@;AcpHzN+W zF_)F$@B+uR12+QS18xJ>0^A?21+LU#@R7G`q{-=VU*tGO`uuz~!0Fxqa5@~9>AG;-iU9w|LJ!%PtI3|k-0+D)x(q|4vVm(~b>mZx_D zJAl6ccLUD=L%=@Z7B74s&;2_72kuM2uYfCHpMjle=rZ6HUc6BT=)g1~Dw9vG|beZWD({D_ln7;H$4XLpNTOur}mQqWVrODE2S!h{hx!Cf3%UzZSEnixL ztx?u^tHo-!rdczs1y-kZytUfeV4Y`OW4+RPv-M%?PV0}Yd#q1epS2EHU$nk#i?Bu8 z?6x#po^6V4uI*mimv&!!s6E^sWskR8?Ai7bd$YaQzBt8}`cUdG z(<9Q`vdsnb&*NW9_t4O}p&^u+OKqh>8c!4G0$N2^(+;)NJH%7s4e^zXRZG23z9x?= zr1%b(I!(z{OHHOkQ>v-Jw7|67bctz;=`GU<(_ej3Q0gE{xFy9>VyU#$S|(W*SXNrr zSTju5lyR1L8?zQ%hDs`uAnr(~iC$>J@ zbG8AmQkzlgIImJKN}*KJOC5qz|N3j8ms$*cfpUj{M?L}`Q)^+;P^#*xKW?}C6|_5g zAO9yV5Hm%Wn1ZK?qSn0){bhsudhMI{??2Xizj;6Z{w97KY|{J&$Dh0zZ@q&RgVw>? zlPw<~!oQynCJe?6#=<{xFk;X=7&;j8;e#J;|8V6;g&*Fa#rs$gee@6f`{<(&KKSWi z$6zDT2Wx;kKKR}TYd@IzLEQ%vK3wy`p7%dGaoq{zb?i|hpErDdYIXrwbIU^jYJ>y^FM8ej53oPw&Wg?aimhXO<62 z=d)qtS0CibINyIhkZC^4hQBCuB`v0V=y|%4x@Zxtr)y{{-A@Z>I?baSsFLQ>LVA>T z&`$7uH(g8LqwDBinq%t58i_CYkv}Z?<0ufk5=voYrf|rLO>{loXWBvQ=vnHgy)-~K z(`M6Dx|MFH%W1di8M@8XNq5i#O1U!L)Fr3UEwsxt7d5$su22>z^OS{jm3Tt@NbC|n z6+b~gdqM0MZ;8X=b#X+T5+8|^;t%3gDP(|Dq^}H>e)J$lC8x}l1+q{!%N%hzy+=ps zT{=O>=zVevDa>M=2o@nCPh^N}ks|^`C%AT+m@ejt8T62tBfbxLc7ynyxKCUwwu>j} zxcD)>E%wlF#7lz2&xImh5hn4n5aNLF5eJ2@cw0n>Ux+}+fl%>|h!npRLE^U}NxUx- z#qUHiq@qQ9C{o1-B1L>Aio~Bqf%sTticdwM_@hV{e-TdcxhNHX2e*}re~9topQ2j) zkEjs;5;fwCs1;v}Nn%KJh_j+qOp#Jdl|f>*G>I-5C@zo*Vuef;E2TwTB$LD{nJiXI zySPN!#KqDo*2rvejZ7C;$~3V~mWx|tiP$9b#0FU=Hp^mhlPncC%ZcJ1*&^ zSWXd-%1-f!xJ-tLUO8UeDjuVE#P3Cx^by@MUtBM1#hr43xJ%ZHAIdthRrJws#fuov zL&RK}BG$@GakZ((G{rOvlD@+<&9ua{*tFEtYnpGm5dC4HX(gopBGWRA0BcN_OSc?S z^5vI`P_odgaxqebE0Icp608I&HYHo$CVwDrms{i=@-Dem{!rd6?~&W&eHdphkhA4n z*((>wg>tc6Dle2P)ClUK^C<$8IoyiQ&(zbC&hZKNOezA0$M9W_(vGUi71!HZp{6MkF50zATQhqI;S2El6uMVk0RREg7~QhX_z#aE(5oD*%rE!wdNH(5Hwr7}ZYC9}kO*(C0j4dQOuD7ML| zVyAeVekFF(uf@;kWAQwl6a(}Jv5)?UHO=3Pr|EZMFMTMUr9tr=eI%Zt55$`yOdJ*A z;$0CXj)`dTo`@B{7IEUZh!?*SG2#=EFa9KQrN8KrFx8xk%Zn{7|_^xm)?M@|dzs zxmUSgxlh@yJfb|HJSwNtc6yASR326yQg$d0nyxloL%vwGn${eS;n zGS@&4n?Q9`PYpDY8mVcNthQtAm*sFcc} z)s3f$?;wb@IViH*>nXF_pWIskvmfHKpVaRAdSqMq|9%S(`NaOb* ziN`?}vn0-dEM`gE2`M}ia(D(L@pX{Gkilr1JJiu*GyQ~afi%AjqsI?ST^L0k5iiJK zdWs&PAJLDowl@v)iRrQ%y6hf|FRYQc#T;m->u5JUNk4;CqYIGGwDwa#XU|WC_`%G6G4%a@YDjt>yr9gRo7+z!$7-E; zVMCwj#f!{=N0tpwN{+R$Pf2Z@+T)mM-E7_5vS_ol(YkE@qCQiq>f*!V%`@|@eKf7- zLi~31*!pT`CJa9=o;hg7 zt8JK>V6#~#_U-TN>D%9sV4FD;p?rra1;y7~7^~Cl2bz7e@ZDcaWm->PE%q18+`O67 zoYv#8^=;U^Ibk!(p}+Uj{!t!+Mtjui9;gYVftr}uFE(@{F*pue0(&@Y4jbs4*?_zQ za$2YLOa!I2nYmo&+0Zrw1rdh60VwX0A6~{o1GqdJTp9uX@Xvl3`bsT$KNFlAuKGX( z2%1f;=mc3{Z!M6dDd=7J-PGL~XI2P(76y^8JM&)R&H-LO1NL)66FmTrnP>t@Y{&-)bzwgjpH5|gL z;o#PSZY`eXKBT|z(A~Fmca84$fgTS3x$fq(i*)iKKE$O1v|4wc(%qxFTf;8mU8lQi zP%;kxmhP@bgYx%Yh#oB+xOflccci~scOOz+R7VUn+6J`yEL7p;bP438(M(($#}n*e5Hwkup=Z$o&;jy|2u>+{)1i({dpL?2S?6^ zux|h`t1umh-o2m57y7zCm?50 zUuO$8Ce>o^5Mz)iY8z3sKY;Hs>xg2*0ECGHW5k<)KB9z50N)eKh>}u(V?>r_;3!e@ zN&w%iYp{QZfES4DUD%6+IF2*eBLsRCO5$SWP;Gw_^ogedbcQ5Mq48bp^{ z22qztl#4uakxt$gqI}Sle~PFu2slkt1UNq@a`h1vTLGkBg0xE!u530@`9|O@QAHTG z9wCn^ zAYMJvtM38!6E$Q2cyGXa!!lq4&<}i0G|?Zh0^5ih;Wi#8YC`(WCSVcJ2Y~LDlSGq{ zb}Rf_%YePWL8A5?qK?mqCMN=*vlD((K-1J-qG^cNh4*fxIURYb4UxfkW=Jxnw|13;MhNPj-R z4-zd{1Z*c-Xa%-_Ww#M6#`h(lZ|N?gWt)gD1eUKMS^>Y6kpS|)r~x=jv?}Y&;iM|i_Mgook z_;wSXZ;Ay9fM%c8^YX%Ft=3#2zML8-G*?t zA>8c^0Q_!;-|g_*vKu%EAnqL~(_JXzT^T?v(bh7c6Icdp0JZ^rz+qqzI79S9g#RJJ z-;FSLBh1|hb9X;*6gWkgssks1vqak>fgAudYy%D3K*KiBux%&Nz1xAkz!9SRDuFIw zC9n~|xBKwzKK_QZ?mJ6#en}8hvXnF`V zJp`KYN;{4NpA$Xo4_JXRpc7a|^a!3G!Sf^hyaCt-^Z|!~LEsG0qhY{jL_0y(PSCXz zbnOIPKLQ;;$^jaH*}z)hR$wR5kB;_OG9{fM)_2Ur7a0(KDnd_Axg z*bN*6P7uAqI8AgQ2uJ}cfi3{_9srF8wgaH?0BAgL5;#kA5cwWNzOQZoknXGfz)|27 z(Q5?60tG-b&CCc!v=05aJy|yu*lh81W7t13n}Ag$YOmTtFMJ z2v`qn1$F}miC#yT*AeD*ggL@MxFZO61mTV#+!2I3f^bJp0%wWdhy-!~yuX3>H}U=^ z-rsBhW)r=&3)l}F2RH1jGUbKr_$_tOK?H zyMX<`ao}^J4lKHe^Pvft4SO)c z4;}$d0G|tKtlAA zfX*r8UJ^a$ z1dvuU(rQ6DCLxd3HNXZEZ5v6nZzIuh9L7P=(CGqplbC|^ry`%JCrM1(L81#ZbR8kl z4NTuoVg};RI7OmoD~XwFNz94_koE;g>w>c+W&?8&cJ4tE^C|(P(~CUjuOqRb060Tp z;cOC%knUpSxdgN>0X<7oNGyv5`bk`fJeHRM+exh0MPeo1S8f6NfTO_YBvyq11wbdT z2G|1h0Y`z)Nn8{L6abyT8ej{F)$0gTJQ9~QU?N$IN#sgQAVKRDVIk~=bu!+P*l==IEByK|3O}j}z4;8oUAORgx`~Y!p zN7y^?e&<0FTboJz5b56i8HsHLB<`IJ;Qc-mfIRO*nET;>Kk~W%IEn2!zy{zLi3ciy zoxoWV4|V~_>mkH@Xbpg{51l5l1GMiz+=t;lyp_ZwvA{MGkHUTQ42hk`N&E;j{U`(I z0X7j9Jb-m1pi_y*ZY8lR1$cqPPkKo_4!_51f%QN?fIOZ6eNU_eb^#|z?6v~4fo;Gs z5>F!UC))s|{p3Ls(5J*6yzkiod`{vi7l3b19VgM32q2%noxm9qKSj8oZUIh_cpA@7 zBTeX5;u)m>%wZCH5q|F)V35SK`2H-?g#IL+TM3*X@v}-`JBfbe*}o1z9?v%e{Uio* z0DRjQ1gr->Bk{sA;1r1$ktXyP0ewZhgg7rF&dV>5*q;dC+kTYe=V8EX0JQ!bVPEM4 z@O&T=K>i2zlQ>uaYywV@c(o0{_g4}2HKhC6VG@V%{SeYUjC2ogB=HNx{lx|V;eWXX zfcttSfO>>pAdY-a;tizxW*C6?H~UGv1^VA=0M-E~NgRy@K=0A@z;0lW#M=n}Hp0BU z7I=ZgJN`f?upKxEd`9A3gn>>V-kl9>1Q7RKB}2et+))wgaH&ACUmk`^OFv|Fe$7Y0!Im7w|cWFV+Ca z_n!#!&n^J@{VM}N*fS=epTw720fhMqbbW>QukrosN}v;11R%|?LF?Cu^YvcfFmM7u z`e%{G*;oMiokiMbkCHeS2_TJgh<|Py33m|C3@iiI17}GLt%oTwk)*)cE3t#5bOHNG zVl0-*L6W8d0PjAXBz-f0QzZR%k@WWm*1%YdxB+W{y})Ubqt{vY5l$>>PJIWv^oN+ipa zAX3PW#t)E*u;^}xauNH#@UN%(=S-j?EY=CGcD?(=DPBNlw4<5OLK>_ygbv95t)``o;}rl zvavKY&Jo$2krtJa$@Nh=bXt6g`UnG&*AHM}9g8_C{btmMGF%_QT>N0K`Uqf;051=P zJru7B3S*Bj%_9H>O;n4l;K{63i-3ZNcQ_-q!rHfU|88}={ z1wL9igHg^{wVV-7v$KSoHWKAccSJc#i%O~lH+l%x*CQ=*UZ+<%kD+*tdQs&`_Yu9M z6>9N@3(Msc)P-`I(4Kax_q3+bbH*{L!>2qLR{>5Fx6|W>h9yE2o#X&W)}OnNZ*;O^b*~D@$=TM3%?Q4@z%bR8_U8DXp&9 zo{yI-KaEHfH) z+JZ*4qK`$=CCqzRz6F5>@9B-^=~*UjR+ASqMzTjFcO(|?TwD{1qNq>YbV0fw%W23a z)EyU_rN8-TL18Ictk|ha&nQQfGs*#}X`8mEzrVEN$+zBmtKU7TL9B0B?y76p-r(K{ z{!`E{IoQQB32V}g*p>9!0KB?sx>~86dIM5tw`sVA;+VoXFOqHSVe|6PrJ$MRhYd7a zLGu9GTcJ2L-R%4c3S>|=W&jg>u%47nYXCWCfT|ECTMbi{k53Q5<0(L1j0i{ZAm%)e zrNuzh3Nv)QS{K=PYEqx#@YI1PbXgWDJc^VS=V2Vt9qz(0M&(#URAdMwYFc`T5)~O; zA&R2?Txr?DS9>W@3}K{>HnQ-B8?ITElMx+ToKaAmSUf3rd`_Z$-r7VTE;7r7>5YcIgr{uy@t|&1n`Qt#IGe1< z8qz|o=JZmFa28=;Mx7gyk_Rqj^sx2i<*RBgNY0OM$h{MSJ-^kJnCNQF=Rxe4*BEAA zaaFptG$HA}>Ak59^Q)@nH>CDr>jleF>7;N38CVTExry)u9v(@9g*S}6wK za<6C~=G$nlh-j~h7{kc=K%nw1^HRK8a0gQ5KHN_Zjc*+W-#WmzxH15I+lVj@HB6(% zw~gT2MuTq~Rldzv`PPASi`7bw##5#G#2QdLo_MG^KhvTopNC&DaE|2C2yX@!xg!}i z+wtt&2=i!mz2QckOTQfE+hgbB)}Y$R;-rzBTGP=gUh*)h``0?>qAnl@7vSeC&hJwNv%qT!vq;IwuN%E4f z+~>r!(u(84u2Z~GHZ+`j+*PMkdk9u};zAHx4W0<*nlyig;Yei07EFUoOYr)k#i1@HZB;V))#>*sbq_)GU| zB52A!@MO}m7|j;ttj3TM9iutu!D?Uj^?E3oWD3g3&du>rLR@(@ z*^?4dtQnIM9M()}sJFGwYn0S?=!}?yy`6)8tb4`Q?(Qv}3gFF~!#_ zNM%Y^(0EeIN>sR4X^k>z6IqX78Y7pasq1`FiTgOu*`v{p5m2?jHP}I-k2tVOV5no8 zz_;qokt)})?wp`xIiQ(2yomvmD7F^BKnY5-wRn-fd z(k6CxP3r1Ye?Sz^G0QN={D!%-XO5}Yqo=0F^mbiOQAcC7c;1uF^UX0O+EklkhCs#| zbId63Ii}eWVHES<&M{5;WZHdFC}Sp>Y%?hPzqJ{t1wY@s4UDDs7wnEY|GbTD24j@3 ze{VBz31xU5Z5Ry%6cdcB(scg$%r}iFo@V{F`3y5-Ftnlph`V5;yZEwj7&)I|X;2}e zRO<&u#`b&e`IArB%!$qOCw4#VUdJ<<8>%LDc24-P0sWijnL*$`C+i&!sD{N}@@|YW z?A@JN*LA49Hw-SzDkg--aPHA2%vk*3#}gSpRhi*QtV7j_RmDwHgG+cinJ@AbkHtU% z;XLh378cAjd_~ce%Q8z+d#WqjL*uRCQxa-ij^who+{UG;u7HGem%VC2XuQolC9&G& zxFV=DFF!piJ1;UJEIuyZbg7O~!i z4a8pM*SnBycy^!WouNtSH^R zAwH`8(@#IGbBRd}QSmoDr_DF9?Gv`Pe}bQ4=|a}7M(TxQBv=%WB~e~#mQJuo2Z-em zvD#?E1c$1v1EXu2xG?4G5V}`z4|~wa4ODZ!Rm?fDK;tQ8;&B3Y)72(wW z;bbBHS(+*WdU|>SDke@|Hr2mtnKZfU#7m5GXGNbIHhoOe3G&|r*1KW=8eTX>c+oAn zv7}dXjv>>#o>VeDdj9}4AF1|BPS{^nnpj=)p%AsD+4Mb*t2QECuUn{o`N9z@s`l^e z?Wi_Yw$Fa?mEIW@rt&V~>s~c|`l@s8FRz?B^-6BvVvJi3mF^zq%aL^JM2zVs9uK`B zsw&ND8K(hTjY=5TE-R!se-xr*i(+S~+W4j7-OeEjh_+-UwXGX}bn1+DQ@b>syCvEd z71;0t@zwEz2SFE1x7Y*Ed^e4i+>v}YrrEu9Q-6@*F&}YF`I7>ISlq&(geTNhv4B<| z1-n?cXm`IP?sY#SX0(e-8`|Bsv95}?Qu%NIGB1!N3aVXKv;9#jpJClYAHF@3MJLVE zQ9UioDxVLT)Vdjm6#}Wwj9In>pq6au4nGH&PB;{&_^9HGiW%>%e$V~SfCX}T@i3H% z5^D<=sUS<6{`@#qkb-bLvrw(peo^|Q+@@QuvqhAyU*M_dFSC|Z6;!k(Iwxkh zzxc=ay1MZXZYi#;EZ!mq7EG;a4plVi|oP80kO7R>$E9Trde}su>sA&_M*n1fv27th|eiaw8dm5hhKZ)B^O>Bo}3wD zODf5UM`fYuaK;<ugn-~pfEx(raM!X!A#_%}=JasOC!xSz(!s)3eITTa9DtI-e+8Um=De@6D# z|C$D#LFoCKXNi9IcG2h_P;+%3(8^v0o*4(8$)wBKI*0>EXa%F}y8QB#eatYL$(Qq{8)8TQikM2KZZ5f9zFPO@RHB3cob9YK%daZ5iIIrrO(K9b~Okyx)g&8SSF5aXI=1k%Bpjk$79SYoo8mjQqrVKNr(_O_uUQ;!RBW^vLfafKCnak zs-+e-Oip>Rl}oqR{drfH2m`9z2jqbJ2o^(N{DWN`3)U)MqKAy8cZ}rp^k-g$V`DeZ zc9=VH`Uhf-$2k_1>e?WLHyio8_UsXTd-l{zSAG4tBlUP)5n~Yl~i7X)0*eL&S@@6 znAU2JN=*oxFi|{-?f^P?{Hg;Ts?A3|y6i>An6}c#FArg3+REGJ<2&3|te^@WVqxh) zQ>cnnghK=`>grNvzq$B}vkS!Z%TXQft!vM?-*>On_-GwyivevuRLoO$9I^JI?OXaP zFUasXfQ-;9*L8_j6#sUuycjiQB+?9{i#bgk()LPoO!4&6c*;9MEb^q@+gu-vQnH%H zL$89xem3v<;GGv}R0X5h5hxsNB~i6DaO5HXyYBLDUwO6f)yvw&+~2wH6-$4IYhex6 zMEiL9(tPev|BHP|QzHrV1%f_5v`&OWBaJ)Dqke5y{1uRG=k}uZ_QHh~T@uSNE96)N zq>*ap&?FffOATqJ&k{V9=G|kMXQ2mzdy~~R8f)j^O$6#p>E`LVw))pkL8t)#Vfw#?v$zR_KK=w^{NYiDd%V#X!EvhUk&RQn>YR%${Qxs_>KGl1c8mo@hD3r@8$K+V;#C?$@sz67IHp=v!!uyEF8TUJIlVS981???Wfi5x z?M1Oh)h}3n&#erKI4)vxq|>pG0;e_&t8j0etJ{d)bUsMuAcMU{$vdz_@4_n|V=56$a>anpy~hv$2;UTbTEBp?P|` zo}T;oYEKJwFY$N3BeI&*Hp0axf^xz47Vv#G+s6F}hljo7hQ4;?q1c<_p_zk6csVHv zPdqnD(m9CN7=n+o86;X2ZCJvBPYQY>bi!eKMVe-=;3Wuce*-Up8pevOx$DxaGB2oU z>l}|=G!wJJuU*k~siPuk>V&4Y@=2}E#_aIxf+m&}Ig1)=oaI5m0V&QZTknE0r!%df zvNFFS7(PYS_W2ia8RO7NYS2D@RK??`*(xvHSdZi-P7I`Th29#huxS`2sE6=D=O`#QM8X4N5_b+ zF|{dO-Xrl<;@f}&3NF57&g_&nt9V^o96Y*KKbi;Q)ZZ^ zcg;S2%eHN|h(%)hO}E{4lluwKZ3f*fB@QfS5(q@{E?} z$ZX=UnTKgI3;FlnMct+ui{}0vS3&H0?xBaCbN9Ia%5_)(S~66ggY7^WH5VDv-#j(M zOR+3dj9$k*78?=Kk+g2hYEnUArZ9{LGQ;gJZgJlz-gB=N546g(mezCcBSY`8yp-ng zss%1TKx7`xc|zUK=UVaLwR&+rme)o<&!|3t=?&}>YC>5M%7y9*<-Z^4OGmD?jvek6*O8Fm0KUuk|A)lL=*UF%2c;ZQb}R0IfGv#pGKdjnw|$qgYo*q-8lKbJ zR2iRy#Li@yys;xgTjkOAJVi#w;5G<-i6AW<_vV~O2SOd- z3LKlc-o@1+rTas3{g|4NH>(Nxew+Rf(YE{rSpLFWo`sdLL-rx zYX7)k>|E$ux`Uy4u!TUIsk8R44a^=x^@zEVUpn?fIitD{`}Gw*|GoYe>D*cTq@kxz z03C4{Z+k8QDIW6Z21}+itqFJ9PcxX=LDSfIorRN}Sj^xJRCyAb1WydPw6Jc9kd~IF z^0JB1HNk1l`t00}@iy$-O-w4uigVP=EXivs%?zsvv7{H-CQWW{pC;~{;PfjiE6gZ} z%Cttto_R$D`+sk#OF^mF>ow1QC!RK`5$b`qnr|6SZsthk3LZyE3q{5rzHK4sn%xm&m(n z*)sQ|BG`2EB=IMAjSLt`@s%x&sYs>Q5%dFPwp0b>Y z#Tlsbq_hHSb9+ZiQE5z59Bv=F*!_hgGpTH{)7f2RAFkz&iT)Ei>~Z7726R`{buG#i zi!$-9uca(*G|jZ1c;BXHKl-=K$=I%n>oGL8!a&ASOzT5teDUnxWR}w`4u;e!b%rQ@ zF}xK(EAOZ^>fF+vDqDWbtn8KLGaK?ked4B0$ZIQ0wqw=0dGh3DASiX>qUw&DFP@Q* z+0i|}N@kZ^$~p_VyiFZV{!Ja~50_Kmb{}l?M>+j@4~TlvnpfL>3nyu#B%83A5s`?- zh*))U!m6%nsZR>C`^{GIj{CH{t54SkvGwVdr(r1xjktUfm8437_E z)Ef^cV*-bIbEOv_3~7;m*=Qt`lAh51PowWpgkD_g|T09qG*O+m>eld_w;%>EJG zm7VkEbyjvq_?x?$vtj&hs;i8ytW$r)^zkJjCF9i}ZWHhxeq!}H8pC^W!?iO;tJj-L zojLGEKAx8{>&B6f+B%ke*8B|K3j$AC5Q^wRK73DZ8gHu>9aP zFSh<@yN(j!YB~|-#Hz`frE{dZHp~ZKV5tE|80^m*gbhifT8UUSiqQ7^v&JNHMwQjY zb>Q#*GOE&{KEeE08Dx~qaGTV^TNB)WjfobI=r1BXCK?pU;j%}88c3dK-Ez+pdT;q^ z1dYO`r;e0*krtCVg0QMzv_X)^s;k0SgLiM75%6Zjc1}=_7Oi zXl_xqp2w1088KC530d~ofT|!zVNLqNrAf|)^wna9yK&0ui1e(S*dSN2Il&Q;5r>_o z^QNWMmKDV|U_vgS3s1)#St*eB*fX3uN`KSUH4p7NU&nC^bGx!FACgR!U{TNwwBu7e zYHH^>^5MgqJv43}sg&vJnW`c1{@9U9oQTM&cV&cChFDVblba{Ew@z+pYH2JhX^5^2 zPA!^{nbTHoi-r!DSeg}Yub%NZD+{?%_Sle?YX8c*W+-r_<%Jn}ssfiY!BF7hvMQm# zm0+UAd~O|DfcrvYRh@0gH|uQrih!pUJeoJ!&gq~h8`K9r1ja2G0arWD$D9`nzv0$0?76xY7d1lD6RFvCuJo|Vgo)KntsTwz z#nBVv#2WWIk$$lgoUHU%E72^rtmxd#qA3%h+1O*piFJ+cuk0B~rJXKkcV)^bUG69U zr7j2Ash2a7%h?BEs+Kd^c=%9z2&64|*a2{m{lHPchnLbe-~n;?=tm^B(BtIe#XLr< zy%V)!obA!ZXOC?D|DX+abTl`2bbPZm*wO}Vuuc8Zcwqr-h-e*}{EfPxheJKQVCaI} z(x&10g$ZLXOE+EtiP3dJOxjT6rtb!hpFsu`*$POOI-Sg-zwo~mL^4J^jVAIv`%KnF}w_2lQj znP>Gz@U)6<&4id30Egl7H&t$`JR7F*A206e%=@zTV8$hOk+=XaQvLD}+@}Ek_!jUX z;D7N1KXlyNojvocqf z&YF-L;^WaQOTfR(!!m`ZG;KGno|%x*Ijy%soziHErTb=YE@qh<2Y#vqKb7IA)N?%I z;PO(n8dAZI<-;14ewM*AGu128vmWB&VbrCmmpsWRS6L|q1CD0R<6{lHt(Z@6CFly* zIOwBQ$ZY^3RSP!&LzuQLx}S2vklMkb;^7!Y2+6xO2cT4FPv|hZ8d}QdxHSDtU!+iv zh$c%+hf6Uz=ZT_&PY;7jS#@V6{Wp0{h4Dd_!nDNHq{6yXTWMC3UtO>(J-683oDeaq z#mCPiZ);V2{l^7dHE)Jb$n^Z|x`;h_Y0W{F;>L{lvf{#sl(yWo>fG1}OIn0IIkPm| zKg?oFi(Ne{eNjPuY*t2Q%$B9Dnx+)%J4r3sp(VAb^Xj23xE=d7*d%M{Mm7W2(gd~6 zc@eaV>wE&wfq5kJHn>l~S8;+(^?`bLKGGO-^m&}~;(FyHpt2YNHp>Mn4( zZ!M3{Ub=eqvY^P=;Mnd3H{Wyp_{FW+`!dq*P0J{+uc|L+S$jWj_I^>!g7k}Fy&qx| zA3-97xSq4~Kj%(DE6YArprl*7D&^BxA}EgA|a@PdEk8 zg?qL6EcVE9gEP>)YzflJjqvh5m9CG6{)L-)uLo5rTu8PsziQVvJxIu_DAgJ>oP7 z;_{?33hA(#@8AJ&#I)1-EpIFI(?k!Ba!pCm`1g%JY;#uY3cWCmcc;Pb$qP!jqk@hM zT`F#%O(=O3&sFp`;yUL;DVP&uiAsp;FRkC?{*=>E!>j2m@JZBJRIbdX`|J(wCN8izMNHk$4OCFs5^< z>c5oz|BE`jXy{TIL7R}y@<+HWmg8DoPG?|frOM9{+!ln9)Yzwvwg|ul7OCoiIMo&- zAp9{QD-pRFwlGUZY=;w!R8WG>zso4ZynP5bt&li$ zjI_V4kn-gD@kcMm&d70+#yaPJ+zAt65wS&j9LnhTHHs{i%VntV#K;P-;CS3 zwQNu4p7IACK)geUx5tPlM#l3+yc&^S8WZJrXzSLvH{ZPYfd|SFlj)d*zmgU$nBKKJ znBKMYeU-j?_=mzjoi4+DFkHUN=Ce-o@z5mHZ!k#1@W;!~8s}(B&T?2%dG6|9BYFUi zV&Xy-mW##MS{$W}kUO}9!K~)%m^D-_15y4&i0Vq_WaA!% zB(?0^O3cx^K@c0%d0PmVKM2|4Bea51Mt0Dl=}RyY@Rs0Fm6Ki2UShGAOms}1zMy`5 zWphaDxD>46>r}pyP}rEA-&BO*x~H(7 zW6;fatZ1||-TJ0%k1m?Ts%Mg>dh)Ff+RAhio>J6eC91VqT72HsHovHm75q2vA`}<} zOVP{n>yah!lx)zuDB$%shAtO>m1khALmvr(X2yrj&_{TgJ{-Ir0Zo->tEx&BX7rx2PFpcW?$B*`5sMCVORIoug(T=p&^6CDQk!OlIieeK)v@S$ie?wXoYzZyHEP&?^gi9oJy7)2) zzSV;_v|>LEK4R{QJhU^Hd2snavBu=ABD3v))gZJ+HD4#hXD_gU)s>c>#}+=^F@#k; zEQa&^7JJk*hx>vvO4MX!1%^cin1dS|IyxE}gUtbvVS!m$eJ{s`nJpDgXNAQa7W?w% zd`C?}hBYoQGAS(0QSC?zONtDPOU_KFapYIGXXZpj=UcN}ekMOxmNh>*DkrnOdUQEc zJ>{%GIqg6N$~j&wXE+VsShL}C<-pK;KOoi$(RRy zx!h{0J$GT0sLRu6hPdxkPI-n4nwdPRpw^6UE2!G0v(Tn)w5h6dDIT3m<2AO3IK?b6 zyV<=HOm&u(4D6xzjx#(b0AH$nfL28u@tk{SvzV<%l031LjgZfLZx&Yc4X)M}ay{qv z^%2-3UpyOyw742qDOe#gAtmk2#C;BGmmjNM?&clMy8&!lI<~-ljK?rNZ;Y)QLBB7B zW8U2eSr>?Pm?W5=bHUNi^ZZ-mXx=r?#%|3G)!iiBP1aq{{Cv!b5B(UUrw|@9w=Vwe zEH&*cHF=1z>6`d@ZlRk?!9x)$hhd8ceByd0)N{{p zJ@YLfoixajV+u`GJ55=bR?qBmJ+qsjyS&QE?;XQB!c02fANUs`@CQeR7DJcf{WPt7$}*hVs9kNFaMX1p^97ISsy?N6D7DM+wm7XD zad*+~w+n~MGk*R%8+QF@V(qYBJbY`_uPwu0OK??`CcINI1Hhi?5gT$nlA4WPBRA$6 zqgA>qEnndNA{P0{A{rYd#GjIIHBsJ7M}BN|T6t}$vv%5~@g?c0=l9JXUtME1C#UF+ zk}^IkI^I$i8IzxxmKzctGsRgjDat3dA>*4qoXB>W%_hI7Qit(?YK8pH9a za9XAP>`#@@vwvs1IsRk^Tx4{5ApOfzq-Ol2S9nsk>z@Q>Hc-RGEtd?TP8xS(%oUkRtPh@Vx0)wYOb8 zJuh$i)otxpP0uS$tDohpbk3^BdPkkY^u>{;$@~t0ZuK7>vzprM&l+H)^xXC;Is)Q8e5L9$|`62Yi2=*%{+huaqlw_@z5yqLxe_ zoW5bg1Z6^XfiO*;Y!c$pPxA6U5ivL3=>FuoY1_9?yG|{~X2I5p*RV*EP51H9FJm_c zA7D~{7FzUWh42b%IKTiSDRhzgvR*ENWIMW_jnqN{r zxw)m?ksp}l7iZ5&tZhl_%gU+Bs1%D6<6_cEa!ZO5JCb7T!Eup6bybg-{V+CWT%p5~ ziHg4!b#g@4Rl2#)X(tnKHfY4W-J^q`K86rXt&bhX!%_8tn~jGcmQuX;HNq&)H)vrZ zr>(2IwY9suH8(FW7Z7uLySsXOySjUeOS3DoOVuAF4LYwEYs9Zr`H-xgztVQb8t1Q! zcJs&s(An!nl}EP5>r{F6Q0e!~XzL9av_wuyb$)z&eszlB@`ZPRSQDSeaxgF6aBHh` zbE_Gs5&BfD7pqiVANE(TIfXj5==N9ij)FoyAMt6~q%wrBcE6(9mF~jxtN7cJX>_M* zf4#GZ%PqjEW%wI&88CsH*$%GlD1`>V^X<}f{EI=KV9S?Z`cviW4@DiC1gtN=rY!66 zy1Lrq$7}2A9?#mXrPYYL1j1nhRL=n5?@g&^g^-p?pT0Rwvo*5GL*Me?xl2Hwqo^0a z@HJdGxQ~YBT@8Ndp}fn1&j9#f5{)CEQGDly2sm({@xXz@^48kH!P;|c5yhY(g0AHa zFCP2q7<(ss=o_!kM}wwk851Fc8s3J@h5?i`DPU zdIx{@ce?xWWU=185fNSVD*h_&0~j`2cz?Wp_G=$oo-hcR@K^cxToPfKHGpT2$OMzK z0PVsdV?G>>kbGqc29s#MjVu*|1Yi2gBZGP;87{lTk~3fYduLj4QhY{4QhHdGxv?S* z*I8yn*wTWlqMKjMw%gOH+e2nA%Ce=TiPoBs72Tk(nyPWHLkf61oksv&F6;w0;ARe$ zKISp*w6G)nvPNtBg7Z?Nj>%7LT&`p$62`P}xkR)DJqUYh>R-9X4AT6uLhX zC$=4GIE4SVAzwi~Gza_MaF+t9Q?v8RLyzduEi&-H5RILH5WTA06*-RBvUMJnbdjwp%9U%!Nh0SXps=y!W5p#YzR|3>P z-}7)k>kxdfmFwgD{eTrOC{c*(K$@(SqUT_isVP>us{AZKPJCz;J*R+s4y#wAdyY2k zL9Kqn7qKn5EIz(CE-59rDx|UOyS<1h5y8Q+>6W;Hn&F2-!%xExU}m)sHiJ(Rp)+9> zBGecc_p!W(OknQkv4Q)6)(g4Lc=0+wcUe-h6yc$cdn*rh+*^65tKnNNFeKQ9#iGsc z=F%G;Yx=8*C}~z7+6AXN)SL~&}A5B_!I zwvh+KLJ`pU<+NY;{{q`!N5vhMo7!nTwtkeR7N-^?a<^EBxO?1F7jn!mJHJHQypL9q zQ&8GK%Hj1atyk(Ia(MjFbrE%E0dj!W>;x;CnIa^v6Xl*N_B1p|vCZ$EsV~pmuSyLg zeZDsc>GPk7)lN)vC-d9`=ozJXX@}D{h>pT`+hM|)_+RMB7dNoY^oWkC6`AV|bqh*D0jT zc~Yq_Ev*LzDsvKZD%BqZWcql&#+W?_602W)pB@{i19_FhxN!k>f_nLubwp1?X#B?J zW7b6Y-Upk4F%bhJM#l(YdjI_?@4uh=#1no`Jb^jK9GnS$v=t{q!`*-7Q=wWP=)m6t zp9UJ#y3+5x@NhnlJAB!zc-4KSIMVU+2KU!$yjaj)sp@7^Jse|<)B3E`Bgem#RYt*8E2ZITPHxb4(5AJ z0!HnF(PvK{-CCartJiX|mdEU^>2_FoaGJFxAoWsP$Fb6irx_dNKU{Sa&xN|`P-gBO z*Wz!TpM;D}gOt6Pcjy~ZmUrm0)t=o#L)vOG1bIRIU>U+!ETqGv8vwb<*Hal92g{IP zMCLY(fXnhV58~Cl;q^8Km3MzkQ5JF zdo|{T7D)NkJVU~tIR}~A4k5>|ywI=X^VlXlJA`;GkPSx`sPudf88l7k+3Bhu>pxsO zT9ayfR%6jp4t#}3fX=1gS2bcQn=KXByR&K*bw})ES6X#pMp$NeUS(Te_2TCA^ybCY z?7F9klK2j%vm<_$B{wpq$P!~K&xtGT-PqBwvA2}nj5m@qGn4-xYi|M{*Hzt*&zl+T z>u59@?b2wZ(Z28dE?crKkL8i=*owErP8?@*a0rxOLJ|@n4he+jPgt51%34TL3MmQD zLZD$OwCS&bhL(Q=Khi>gmZd3Mqw)Xyz30An-+1hVe)@MJy_usoZ|*(!oO929zlmvL zp6I=Wc-7DG&0VZ548U{SqXtXwWTP`r9j7*w<|c0Y^Z}~%cASzstE5P_;GxvNb#xLL zCeSR011h^w@@?6kONZi-mM4*IYc{@CSXC4$>k5UMha-zyuRh+{hQ?fD`H}94zPjeQ z#m4+Y+spF;IYWij0jp(bTX$&BuFF2LzjI-Dq(55I-&E#(d~j)V{`UDmKZFo{f$f;^ z4)2qo@d~U1ym$1w*}fV&MOm1^Ym>gVIP*7t3Xm`@NVm&L0n)NcwS{I36_^d3exf=eym=0n!m*2XIv zTh*_-M@JQ%NIzaGUG1ck)VfI17cY$;Ol=y~k8)j+#wd1^l1~gSNCtcfAmjvSQYt`T z`%%im!(Bc`{ZpI2d&}z4m1g7;wCx>T?V8$I;SWsiIy`V&^MTdDAM5VDR`K%5rH00Z zL*wIz=Nm_xy*<0PPE>^FFFTw3>UJDiuwDI9^V{wTp^xeZRc7EDxD(oac$95gs1$jm z3o6#2ZNPrc5|#GiW@&~`y(r^pSvsjv$%o+JDL2v*CkOJb*nt$|?jRtQ&gUsR!?c1L znqddPi%wer8#wI&<)Y1MK0hMaG0?U+xiU1gGP&3`;C?+cJ3EA*n@V3DJu`LU#MGJS zt4rOl=V#|;=4RC|HUGr9x1nd(t1+4eR;01jtDFhMx>w8L17oacK5*Boxb?q>(T}_YMh=<-Vs>VQd`MXcAnId<4<0*y>D266 z=?t=(+s6Z=p*;(KIx;jga>I#&w_Shz+X_xxkulrT5N*#$UZ2qwYwnrLxWd{xzB|u) zYu@g0OcVR5-D|Lg*~%X9p}S@|jmzCqjytL8H7FImWtb5gmC|^g&4BDILu{c8XC$zT zD<{r2H5DHJi?crD(^rUhlU-n$K3|DDlu%SW?evhNhk;%$vkADG7A~P_VR!x3c;53r z{NeL?@vZgUXkECJ(z>v|9IXov?poYf+;tGm3(MWgk=taJ&g!gQKTAdvnfoMWcuO+O0^JN%WqJ1qqELCNY5Mu+>6b1`mZ!A+DE^%Yv} zR^31Bemw7q@|=nBzsP^4{_(uWx8vnww_CqjxNl%`@vjz=!volS>DTa}&COE~&FBqO zfph{Y2)5ppCTHDi#KimxWD^Syw&9h86@+iu;gHo!Oz{;*UIWJ3k6bEdi$EQ-lkQ#d z$1qj`yoa1cDCpbTnta|Pt9c=I3Xh+NOjmCko!b%2``Tl^qVk$T>y{INg>IaVve>ZD z*0nlPH{IXa+BvtQb*_G~G+5W)TAPy*2k!dL-Q#WXwPO$JLP-3>RJMma!wQ{bp>_<8 z*M5iV(z3+NoTbkJgNcs=UozhgoyWl~yaZbbuM7-!;L3CWc{p2$mg+-QsFiWko- zSVUXjwwH_7YJ3~7_4;Zw6YWkn8mPO4Adkx9pfmNAlb|Ugr6_gOi4@t@VMpVm>+rWW z#UcIJnTnvp_p85i!lWF!=S7A$;~k@n9hiOwN-P4(ZD$uysa_fFmTwS}Ol#^s_QKPz zt);0)ADznHe|)C5va2D;ma*lNhu(wZgGMI~y=QuMcKXyQ?-Qp^9bKE>QtZpFY#xZR zbFB5&7hdS@Y3=FVe8xhsXke&gaF903lEz~T3q(ABLAh?%k#%60T%{q0mtMBF0J{vb zzknn;WZ*%f^ivt@23}~u7U{M&Y;#qF(55sTy!@%6k)cZt zFZqEJlh)5GuiNR8 z&%K^oIZZj4$(GM)z1xh2@LAB7A6lNj9ou0M-Y~++v;&eOh!ktp1_Wi{%8W5`8q0l| z0x8>z-SmxWgWazMZJB@@CA?oogaeJ>OV}Qvh_^D?8*R$C{4!r&*6L_RW{ND&@c+x# zv+}*4ek{AY9OArisByNerF6EWIpmV(rHz5v(w4H>uBl>>h!6bh@lHbikdLFDFOmKl zb<-L0E$Po#jA?{=23Dqp33+@L`xkt-%5b1QA|WXYU&6gvPP3w?E*D=(Eh`6C zmZQ-6X1JR{D{5W&xH9fRIQPOTT*?2upJgjeIG#|s;(=FqDQuTKMVm4=GGKOCZEs$h z#lP3D>+6YYvaEuv)4%!6i?i|B7xl&JVJPx>YYMhzFKp@^z|$yFyxz~+IlDe@5?^aL z)I>D3fHu3G2J|y8tzYue*!)$Zb@PzbBZ^70@deV?rypox?h+RVhb1UsR)zcI42F^?hGa8paY~dq5FCuC^8ON6uocUh<6R` z3v|XBTe1pE3xf4+)8iFA;rfEwKt*L{L1|G@aZM=mkw|Sp9vVUP; z;OXYZNTe_n4B|tRZM}0vfuY>|s`9*Gu#6w7Z>pGB?i&ABbTEH&Pghr0&yPWJK3Wp+ zS-9vB?JzVQRsh5F-SFDr$ii2SDjL$$s5j9z`t1E!Rq-6#fl}IY%zS1%N9c?6NV;Q; zO+3e<^^-0MCmoXhNQX2w8gON166uh9O|EY44;v>l;%;oMHyn%xBlx*wdeS#GlC{le zwfL5^M#g-T)7iJq7SG-qAKH;=Rc7uO`pxW~*&qMxXFtZ}H`Y6oSFczr$;bGE(Zei2 zUm7|-qMjRBC1qobL#=W!bup#EY`a=7HRitQw~jpmSRu&)iP$lyt4TMADp)=|w+Y@J zby`Cq;(~aG;#usLNOng*%`b0Hgj(u@JF5GZS|57L`&$=#tG5U1T0-mI=43^Fb!P;J z66E)-^r2}{IWEZuty7WC>io?+KoX#dHO#LA{=-Tr7Cqb>t5B*bwUb1;AIRpl)0wH( zhK#4Dlg!pB6$wGyT7rm?WM=1n7jpay5IeMHVrqJwn3^Qi`oBAyg?eH?>WTfRCq|cP zG=cEoHg+AfeM5!#u*7DWf=G(su3E0F;ws&ru##D4t`bfhlVqZV>8HhJ4I(i29)H=E zzV*Y0*Za1V`5$;-`{LsE8_V13gJ8k(sfC66txr82Yq{>q6W6uG-aI)mF`29%E+}sZ z-Gn9&1=B-|L(@+(;zZrD8u(-Htsqaz&Ho?kmS*$Q{9~wsRusBPDv@NYs0*o?)Ebzm z)Xz4cbooHhq?*abtDi}1-I^Hs)}d#g{rm49|L%yjWzp(Ft5y6gCLg!9s5wwBun%e2 z^EFbqWEj}{b($kfm-Hw%6}!WO%Mm3C;#HaAb#k~^eB6%ZOe=cVXiRcW_Nz>Mk_QRW zyvf$;G}9==0wYIA9YZc4Obn&#i^5uU00RXCG@MR4`8@S3c_t_V8&uZdu=-)wsc2|8 zgv`v4wRin2{{6_?^dnrp^PNANU7S6DpMPFn{&RWBb@i)trL3$4!?$?4tOwa1$M${F zCr4??YgAv2#+@b$+>6XCODZfhL>l$#W_Qo#fDN+22H9`}WP5&wAO9&h4D2`FOTjF+ z0D3Qe9>R}L`bA`;Uj(9a$OL452Dq&bFDx(;ll6sk{N4EIa{&AdKj-kHK3aqqRPT{w zm;-K=H{G~S;|Ga}9FMT&bGY)OetZc+fiEgIh7b4o;KmRunG&2fBPBSki7>9D$x5se zBhWICE_y(o1K?-)!G!n~U6^v01H_blC{FE?6t`RW0)j*AiEKQNI{?3jwgtbZ1y_Dg zr$hU~_|S|&{bi-0YKzVix)uVFF8pTLI1E>EB=o_xVf_2W>b}7IsAY}L2llN#XyxS$ zb-&LlO8(+~-9tIa|FYJhr`)%?IGY>H%M0etF0NXa#oE4~NZj2Pi&J)vz^=%Leb#`H z4tqc$HPA1l0{|n=O&A`p-Hz-`vo+n-T-4X3GnWSsqD?LPV=i_C)_3v1Op2IHWnYli zoAj#zz-rX}15jPqep$Xa0!tlDHdVC)_DXg1YyX&~%m$zGp+QRMWNYNAARS&cyF&6b zb(APT}hHC2uijqG%Hga$wW?5Hwe_=(Y_6*&y%s=0V}uWvTLs$^)m zHrm_OQr?--FB$hRZ?2%R%ehE^@G4Fzr0Ytn0ywvGV`wU`~x>z$@;VB zoo0X3kpkj+?$Z`Y$Mv zc%{9`dC_ged5ibI9xE)~|9b45{Yo=qJ0s)Qq}D-c*Q(|nQJxR+W-1T2Q!2|40M6*L z=MSuCOjy&4Q_Ks6p1P^<0NR-ae1)r9 zn->NfGG1l9#3QhJH@`i|6S1)Tpgi~rkwx=UEPTD^Zl(v=>(3=Bb1a**Vpi-^$~nEy%N2)aXe6}kK@)+-NwZF!opPpa|`dqfq7p@&bZHXcIJ!o5uXPymwMjD+6ns%5yrvI87MmL zXy&Ww-K5{7Whl7&4J#HgXLHgKVG9xlDaCO{i=POva4n=zcTE^-CZ-Fomzw13-uJ%u z#WBygJv*FD?R|c;8hKERlgE5{$hY@nf|>rr&v9|!h-+#1z7%+d1K;b0dp8~Uo)q{q z4t&jlClE6$*6=Lhd%x&BKhFC>mviTOz2n|uc&_I? ze@r#V_T3EuTgIc*Ljm67I`J^2K@JlqOcn%4jQ~xTD$WSQ2Rc>*o*+?k_Jkf%dtWt{ zX;N`-1t2VxzZshsGctSdmu7VjdkhZX751@0L?EDJP~U=Ij%pe*Fxf9l-iUnjRaqnp%7rfo;>f4xhO3+L?*|xzeHBhQ68R z11Gm8f46mRw6Cr|7AkM7D$NQNSGESqTUr_?t9-t$XlH+787GO1jX?Bb;Esc((o+*P0pHvHn2Gg?Zy10 zJxw95?>C=&jq^Fsi0==a=e;%se#(LWK??jW4*ZM*CvDoa9P^Ww-QOQpJYzn0i}N{t z{~70bZ%l#T?7(l+@CoEH{Z{cFBRAl^7lro(&iM;`UkaSOC-A*)xEim(_oTp|aqeGp z;0d0Mtl^wloxg7-bCl6iI=a0LX?~_$MEI90`TH3MW z?}VxJIn{~Hmt6d2<(_|*Gx$pIPred-?swx~@s+?2xZ#Sg1itEqE50I}d}Xf})?M)x z;ovLWuUFr-{^Tq9+-scAkxt210)K4^oO~tlKQM4_)JOgh_!$RIx+7l^4!%0?`(b6!L=x@2;&m}GUw|l-xC3uHtRg_>*;P}(fOmW@n zP@>*x4dLl9f&myqUlXrsHNX@+YU&of<#;uOS2Wo2bZu)UaHl@_0><3}VJ*`3aQgd`D2wEC0vQ;+r z4kp@Jys+2E-J>f9_D;9QV(o>4*;P%wwVhJ|U+HY~YCM_j?GDz31DRvlRXC`(e3)}JhHZD`oKh~)vDtMyTGF9`tT9)1&^z`FSroY~xfNR)oB8wStDg%cLpi*Pm(x#hT(%2%?n%s7(* zB<;^v4hs@I6A^`x0$PGstCTewy7J0q0?KOUlR|jA>coZ4k;3)w2aX*(aJ<_ekNdl~ zS9C=~p=ejd+}xbCaYgTB{@ZT3u&EyKd<(%7jeG#^AH_v zo=?*CX7|HlMADpLlBUJ$v@|ne6M)o`!-UJ3`vnI!7bgb>1yj)B8Udv-I4(|k4{l*z zBOR@cs?w5aE;%}hk7=pmYN11r1~OeHWv~UBQZ_Ui22zO{O~cej%|`aX1M%6}`0cJO zOz&NfPAx4>Z9X*aAZmSS-;(Hp6Xz(?p9d|mW{bNCqBm+Ut4V?1K)BM&U-XuFe*q@y zXI5X#v8<^ZnEKIr5>ma3kThOFCX8(XQHm^sKmew_O(7h-{6W2{e@+e67yZ^?n(r zY8*T@d-km=TldgH<4B8l>Tt_a=S*c+D6gV;c5|!A*abf3A=^Sqwz1v?vW;oD4xGB4 z!1txVDf0xr*9})ZEpT+KH_!1>*Aw_!3Y@$y@Ldi(fwOR^0}{@4gF)|%hxV7;zwX?R z@jKs39T4p*?E94tDEG&CKh}93vKi0EUN+CH8yu^rdmGa)p!)18DXpWohF#1$Yj)8B zLuq*Ai+Z)(49AIiRS5{?KEgXl3S;z(n{cnnL}yoldE8Iuw@Oc@J!R)<*Lu}{sm?#9 zXE)2G^aYtbA>jw>MA!5wZ*E=bz#cX^**iF}K2e{%wXAi#abSDtcveMiq&OTY&C%9Z z59+S!BO|S0XnzZXK7U4R%VkrOmoGKP<~N38`#-cZ<)i>!+_SwmW?n{{=ILB>rp)iQ?hDvDS1+Gpyl zbY`pP=w)PH8~9jOLznnUr*5v}q!rd@KTcW^>x=f1gir1m-ap+Co!U3Rwc2B^2>ifh z_5uvdV_hbvuv4ma*>zF2bP~MeqwfM!^`czACjx&bGSr zj%8LxZ_#GYPTQi*Ste?)3Sh4a;Nj@kfyge)G9{(lq$|_qN!KQ`HJCh7sg^hoS6$Sd z#{yJt-A<+!PrZO&16d?0>=z0g#a8sd1ODvtXixpFqw%SUavay;J`~HlRp6&B`mWK(5d_`f z^@Af&>1V;KMz!0c)b@#zT>-o_NB_eK@&w!MYjF zHY8_Y9P0bH2UyMce(VADxHxdon}+X8fnyKQ@V#z0=|sc#q`C z6jrLATW43YUI9$@iQDrlc^9(l+lkH8rC5B#zai-WUf`JW0^26ObhYHtxWv3KwdC}l zlx3g1OD&ztsQ;Aq4Qqp5FR9C#_bt|x6|?Cze%b3uyIkXGRpH6+SwlTTCh){+gi2&_ z+{g4>spw!R172S63$HxPnb%s!LcN$CZP-}5MhxyugoXK&b`A_T#{z|)Qs9U9V%ivO zSh_{wL81dyPT+o+}*in(=OQ$wVT<}T{H?IK)7g|1`eymL50r*esk|Mcouj%o&}zcXMv~VS>Wk-7WnyiE}@hxhRMfXb~c{V{0{Em zp`B+#T-lj=bPbC;p#XRjb)`TX&rD9s@D!?*u%qwH0nAhl5hTNjtq~}o3ROKQ;x!b7 zsTj6iJw9xscy8chDHsMGhd@saN*62zW;6Y}@SF5Lr1oaoVT4m-7kWJk>VHYuiy?J? z!YB6&oOYqW_qyTg`2yc%!kJw`S9NF#cRDlaaxHLDnjqECr5YG|7C--sAd24q3K_)hlsem%Af zYL!7aI?|L$#KE(oN8{&dlkq*54h(wziF9?+`#i&~M1v1Xdfl1S5bfD18 zC@ol_V~a}iONAmMfUJoiZAm@{rcuOcu=|15gdsVdDRF1`y@Y>{1=gTqQy$=qE-er2SC#r6@k2>1b%S$X6ucqX#_i6l z4*gfGv~~=`{$b&?9OgyeaaK?{?Zd&CxT%_bW!;PAm8}2i*jh8)>3aS2{94gZnr77R z%tZ9S5PiUv;T;WpJ$0FSZT)9FvCKc;Qs#nSHHvnCr1BEYTms|?KdGw)pUhun0 z^n&NP9@XB{z;7!(?*(uLNLe10Nz)rmoBW0$G>aR6o1={@0BujSD}e2Nxl&c|yi>6! zV&M)zic33A{*+Wv+ns~BU8*7-*`O%f`r!AY?Q zh#}5vQw*}Qr`R&C#^Nahc7o@lEuEb$__1#8?&#>gv~4czYuVj0#xruqS~_M+d}Rx5 z+qV6tx2vQ&RG2xIRngenvt`d!d$y=wzebP?p|0dNJ6hLmvg+<0Xk&P6>m^Qj41)%3 z?Eo`%E4f?&0BCE_4X6AS_+A?hUA`L;FM(5+&sWc(Z#iE*hdy>Z=bSu8;Czn2_Yw|y zM&E2c)^^o%KjUUQJ#nDT)~#B(Y^|S?w}Tx<(W>@A3kZBfw*7L{fpE(Wr} z!p(y^ia;H$Shha3wbkJ&lrs-lnS${sVM)Xt!>qfSBE?v&@ee$ zR2d2Es-7B%UVAXdj>Y|*E3`JBG1%JO+}7SzT2-9m%l8-74YW0nHU~SJd-KA<{I(Vs zd?n#?qI0gbP<$(EvBz1X1lK~Z5#@-)?&h)9ZupIaW9^nfGxgB7iakdudC|SwwRU|G#g$@o!0>_8P-}0x{-cMX{W7EPBoOQl` z@1@6!A5H$!doB7*y=Uk5vko5`h!0gS%xtNcA6TW>E-p(eTpZ;cqr!G3`>z*Ml!zbG2kffQUWF7^1QqJ?G_IvS9ztaWDt3XO zQ^$_fsm+raFAiHFKCQbXQ0s&r_V~(5)!_D?!^gWPW?CD|x6B{dHN3OFKRnUY*ebsl#StG5%>uvk)UX69ge4eI^Ab-%=Uc z@spo?;u9~-4ZLycu7yzLosTWtHTCBp2gq`n({I4X+>z*y_-juo;jr&8R|kIDx=HQ_ zf4ku~I`A6!HO&1t5Kf#yW)o-K^sq*Bg@lbWO+2P9B`vk8k<_FQpl=Z&-U7a9!Sh-! zAjBJiAhsQ6#hl$3MoP@s_Ng9!X~bWI!_F7hz~mr3`wkW%IHFljR)lq%TPznc-5IGb#q4xMQNDy1nk#?N+ z_l?6UApxEAd@8o?MA}JP8_0|LmD=A?-O2r3$B_h1`%U0>oJ!!__XWP!eZSg+1a8N# z)cq=UEbukw{seNJsc#U@y{T2v?)l?cckahHo#!)d;J%;sn>>Ga%Kfz81Rf_Gw7vma z&x3EW({l?m1jKf;X&BowC!0o? zC~#Szd#Y^^s(@ERD%A2|Mp|af$Vo@gV83@83^K=Op^o%?Ur@LCA4F~!q(<+3XkT=9^=Y2OhK0>?P{3isPS76o@b$F?^IAseF# zUa#)AeLf1Vo-c6w`2y$j1#UlI;FJM`6VKmJ<2nENDc|cnpE97s+^>8I@_gk>z`XD| zuTPCLg6A9iTHusn0=Ioo3a)2;~N>&lhamx4BDLj7~&!Mv;)BjfdCO!rJ zF$ey(6!;?!{O%O^haEVn#XN_0wLJgLHXP#_Q~NmK7az}E_Wf#{_Vcs+??@T{N$2@@ z@P6!lb&!!ep$}AhRNWPiF_VMnSmA5>)M{C1G=sGIhBP;;*$k;hvAzMg()A70x+_t4 zMAeDmN$z?N_iH!Rz{8*L^!pE|!h|*Z5v5kaP5#-X}Jf(uCqyzT!QS ze8k}PX=m>Da_aWl`42hpx4YrKTOIiAZn(c&!?XPN*|_Bz`D_}9`AGwMrCi@*KKD-h zb6hKbqw~B!Oo3N9@cUEX{SN%yHe7wbz2-4LX)68u&F9{0e@=aWi}SqqrNFsw2+ci6 zIA{ft1#b^%r3hh^v%Hk5(|jeaijM1gSJY~ep%r%pmU7sbxGJ?RJ7^LBQSeA?QI(MR z43f!1_(Vb~3};E7JZ z40#DWREb*eNqRBc(f*w$8rj&;J$no`+%cwpOof%TFfZ^5J+##;=*yy7mw{!Kn;Kx_3r;<^bB$n@Whq#V*9yo>N#(0g9V{=|7O zlcZ#dHd=v0gW6MIux^4VPvALz9S`LxVUl@6_eDtIpY?z=?kJh0XOze)$S7DNPEn|K z4yx+VXyAA}{<&MuoVn$5pGmB*CoaGHZYw@xwLSWYPdvJO`l_q1I{p2bG69r ztuL$hhrqj-kDe#FHl*P-DexOKd_U%&t!R^e9MCLF7C@T zuaSA*?!wccpE6tU`feLf#JA7JA7nl5*H~A0?<9U*pL?(UIlwFYl=bqw_qpKNe#&}* zKS((EYy^I(ddM{9XCl9vhqrYoHQnSArFFbB1`{1Bpmg0?PcF-P68r<(~VV# zD|^la^p#2fmMU{rIh9CvD`8(^5 zTW%3NGS@?B6m~oCYwNgiM^+H7H59A|YzoxtP*$|5`)%D?;FM_sw{>fQQ>F>r)~yB3 z^%J=Ly#l996S%Eg3!LjG@H_GCq>lu~=~pt1@QaVr*11!^*XY~=r%aRQ-@)fYrm^0r zLdmqN(q&rO&h@e~jZ*7;|9cjelABjHu11DQN-5K)+?k8Eq8&**{IK=t!w;v+ulwe` z1M~Bn-6aGL=M`UUt`Dh}62JabYI#aW@EmYc6Q?j&T z{WN(c28eNEeBMQ+`~5or&HlHZy9C!P*mWOKe_z4)zo%%j9dPWk1@bw8lg0#oR>Rlf zhea=5z^N-!M$D<_yc)C}R?q2_=bsb5?P~~Ed0~WS`Y*j)p8p!;orTqNQmcNum+>*;N^0?dW#SGi^p zZ#aqczQjNLgSE{ns$7ImEBVRA#l?RSJS|`zr9Sd*m%^XTy9>Zmslp%OhId6KQN|!| ziR+2q#H+w5O9Xy*3Y^auxcz)1!w7dhAL}oAkihLdLh=Z?4gz=b+c5rYWn;?og>R4- z2!9Kn1I6QJF|^$IYH3}1(o8M01#4qWx7aaGIqi~G8WDMsx%G5NOT>;nAlTU%dM^7j zsti(e#X-ZpK>Viv$l5+U{#&cA{Xeo`asP#P`K1>TxVzoEbMwy>E|t!pXuSuxF*p`D z<+#9YIWBPGUf?$F1x_9lxGl#8PB|`cTaJ%l&7rGkxLFgqpKC7n+wwZ~`9@v~{8P^T zZ_%_MaLRFcj>vK52R(!QpnpSjooB`%Kgi=$p+$gI%HU>ax7W^4!&~m9qCYeO9v~;7 z@08*YDuUubrq0x?{#pdSmGe_doL4pYo8%SN(#NGf+vB|I)qkayB^7HD^EN3$>|Kto z%B)ff1<}NZ5zS^_tz~r_819~!sH~`-*qGTrV!!<7%3^fGF4joLnY8NoGDHGZ!An ze&bYX4(L3fek*dz%{Sj-6)DeBaDC67b=vvLx3uW(dG*y-UyV(lc%x0O@DJ&r=sVjD zr`!|x-8LM1NR!&T2&WD)pq|qSJk~0?iudG`bMhR4^Em>)n{e#$ozO39VY@A{VzLa` zHCr)N+v;ediQMEd%;4T3rp50x4ZqVgHY_y5L=|yrUJe_Ou?Zf0QFtXsFVx^FE#Ld_ zXyGC5E#p9BMw%DO?UYi<0?b&%$tO-y0eaYEy8Kuc@?}gVd%@bt#>v(L)7#@?=r6O_ zR&wTK;#JY{nzh+2+ecTn@rZ@fS2;!S?!=q9jJf{4{=TK@{?Ysb9IY@JIec`azdwf4 zg1W~G0O^}*xb*6bJ`)Vk#MZZG}dqdG+gVs zo>ky-lC`}ino!M}=)`ohYu9qsYQ?cZ#Y)1*m3Xuyp|fU{J2@*=Sqv`ZEOHCpi!2;j z0i;6P*T^?%E757|J0HFL)Ttw*8}(!1+1Sv`%<#caB%g-zy!H=%?}^KfUensqTNjxb zZXBN3@$BY3LSq%6ZT8kJ@*Lr&B`1v9^U--LW>LQO}hWBhoe7o_Ct@V$!6rK?!eF zW9_TKTWCI*##_=Hn7l;?WHF{+egO_5G9Z@%?qrA&H;WVnh@8NM(;&R3mX|m0Ny&ep zoT|f|^1;}s;>m^cfT;+Q?_zosq-mccu0JoLuzdu9bUny zUkcpTFXev9M}gb4AaL@uz-|3f;M6Y#ZtIsu=O^6I&iUi9_t(_t8~sw?)Gy`vwtgva z>X!ny{RDB)8f}4Idb%^Uwqw(p&ipbwrj;54Xs66*cR53K%7aq<+p!uFVhQ3ZY1iy; zS|tw8fG&vE-PzqW?Y7H6*T4t z8#whtfg9WDMejc7hxML~^m^rbaSa>vq~WWQcT^Hzf*Lt4qVT)yB#Ur^BIi=%as*w- zfqP7)DkzTx5`qmjF5OZQ3lw$l-LhqG_j$9w{x~ufKD2szwYeF;Fk3y>eM+|*!z7I^ zOxnYoppy}vN_+16-L$t)>2`9zt=kco`r_<$ivz+;4yH`Oi=NUi2Q&#m@O-`CZPIx9I#n=d6wqm@MiiT7e^P%7ZTQN4p%e}|LaGpVR6E~H{$&gM(om$X+z zX-{-$&>_l-JZct5Mnr@6pX4^1ChMw0q$5 zXQ*g}-<*0@eVV?e+jJUzEyZ(>uf{RJ`05}X>-brF?*4!|E^uitGyXyk!8C18o8MN>pf?2_kFX9a*JFGl3 zgnuS^$AAB~^#@v(LT$f@_sMmwiihUv@{_Eh+ zz3B;n>`JtH$HSzV+T^Di{mi)dXb|RUO$}!^`XHE40K?YtKj#a?jG7(ufh}_A!EaW< zQi;>ido*o@pb%uKsu$^^f-{QEI+a9GNJ3MFAh^u%28lj=mWFnhOYb;nLSVZ=d zAr=#g#%T+hj4)5shTpg|7 zGO=R~=g=-pY+V|iTsXV1eQDcW%lUtBoi*9BbEK}Oe@pAwzJNdD(p>|)$Lp<6PYpEo zjxS8yn!I;zVt8`w{c~F>^RTCZ##uFu&;XD3;?aw?Y|&FQdzM?Oa}Lbg;QE9hQ17&E zNd8OQ>qmbG40To@;O@O`$0B(HTr_G&IPhhQ1P4(u-hi z07uoa8y^IdM5}4c6j|oDgG{UK*y(*o+fPl8F07RdG~KXt=;WS*ozp{0Q-ix(`*-K> zUK-m`oK-U4*wR+k+uX6dFtH<$o!8jV*45P3dz7?+T$gjF(GP{EL8y~UOTeHk|75Zd zMZ7&DjTkc{?@! zcC0TaPc2(FKT=z}(0|v#KS|C!efI3r*3Fk+`X=lSkX@<|k&;!w5#FmFW$Jwn=3&8a zfjl?Nqn;;EBdb_t7}}%Pdx#mmRz(aTt(|Mi=E_#e^`hGw5pA*)S$FG1nkerc1%O8b z$g*bPDu!w?+=5{sg{6OxDL@O;5}8>xj59w`*P@Su!(k3QP7jBPAUQDv-{$@DSpVRmG@8I9(E2}g7nZtkc!V3l`&qJPn zn2C|8xz-v5Mv|u?>6LL}jugNm6G^5rh&X}HAQOdzuu);@tSDA&5CJtnNg1TBR70ks z0*YNfP=PbqUj`F}O=*a*9#3xh&gc8HZA=gsli2HC1i!X<&<84@Gfiyz(4H?t4sL#x zYOZ2%tW<6lV>WWyNi8T`&S+}H4CtJ6yCSvwtG`e0T84?Wr_2iDnA`F4)zPgp{XKWR z@#yfv#Bz8fdiky^j`ej8481CUpr@veXWD)PbxIZSCGMQipjWS5L|D0?zaN9xxY^<7VPI z55{n>sT&ZDmZu)%ff*5z%<5+|`oJ)ff)B;B{g!VVekV7#q4&#gZ@2D3xBaZ!lQ%*J zNiB*W`V7)%aC)2P+bDv3%CQsR`sWh-hsd(wbO4#8Lf*;?->LaPq#{Jv1-I~J`1?fv- zqoh#!e2lzFn~yt4prnaA6Dfern;eEAgF1QUicEv9X|-zZ!7HhGGXAOu_q`)nUQ${U z_`|~wzABE=rLE6?xuOh%4wrxXM?X?)01eIh53E6{CqctVPDrrVAninU_b~@*h;Ao3 zTPsVe44!*Hfv2`SSQj+O)LzHqv?^zc!bREE-uUCK4Z*JZx`p7t)Kfbb)eWIRy z;e}__cS2#pj!GREI$|DJ-)*y`eP`PG83kC=quXMoqd5=d19vy>72O zR&B=^d0@s$y0Wd89NEorDv;(K;S;3j!s8Hvc=%l$|C1RuUjjja*H!-yrI(Z`oE`yv z{TeuZIUC=wv>Am)Vd^@HR|I2F1Qm!LvF?xm3SUp!fmX`zhgzq4;$(u9l}u7~hqU>^ z>b>e)t;Uz7#?1?Ky+-h;7gB zheeF{c8yLZ)DhYy9T;B+Z2tg!P&;^zQ3>24I~Ai-1EsTp*KLj_nigBE7sbpVQ9B3> zV4Qc1dyJpS?J-bnJjgo+#f?qJfuy_D}7?`ycQ&;ZXdF50^ zZS>c_cE8&^=iH?}a304RE><+R)SdyUEXMxLbIx3 zL|PAQ$H0eHs;kt^OM(RI>0wLo6x$B`ji=ZSgYGiy9vL`x1bEa)ge@6GVK356&`G}5 zJ8s$7M8%(#mD{spsJ3=!M^EmG`(L}GyW5p5Bh!{9tp$$x zel3iQ5=Hl6w$|hWkm?*%g}zo}aFGj^RmxB=L8)m&XU+D*@yVf3M?+bBZTSKPmG&Is zK;(~s!&1-b^!+)lHA7kfsoAjk>1@@RjR_gE8PMSiuucV%%)ApX=$Mkmbr#Gpe@?C-%mNCMUMVpx{Z$sY_v(ZCS=6zm(@$R2a|vFV7t@Sm!yInrq-Mjc0`fyq3X=+>dP|axYN>{n z%u)%dyT;hYQ=szBvr*GpwrNpnmPYT424KdR#=sh2RBqvDTDWvq7pWBzzn*j`Vfzlw zOMJI34wr+U7?zY}b6+8vyhX$Q%%ZaTaAzl$s;9TUwLEvFs%Z;Wa_h>_@@;&i?++JK<;l?7khVdlukl zYl(i5^oB8m?^-eD5HyAp+%@g9&rM!>n!tDS79%SnjCg9Tc#-pw1)YP@%RBlfrGpI9 z2W!-TkqD(agSZun^Ze$A4qSE5bNBkrEGxlnB2)SVBrS0G~aew>e zE%UuqwZq%H#%HD{a28|!^+&C#|9ff8z*1Z1%3$rnxJ^G}q6^`?9QdhXp1nNdNX}}p zF`MR&HI%>(*|haNxJr>Od+xJ5mzp_sMLW_EMDr)5F1!6v+&a{`HtJxZzx^H$u^F5x znF=4=HN1DC9_Q5zuO1DLm(C4Ehnq^jXT`F4>Op&JWMH9rNi@ENzSg!L9%q9DVlVcQ z7ZERJl&Ht^)+Z3F?Dh01w03~{^PROwQ$)0dZiKU0oHXHVg26D{#R5fH1T0V!wnzi50C0TW^X@w1vHgS5}VrFsE3{WLsHT+hj{wq%1EF zzw|XfG<^Zxz9xnI!ecQIKtZ%;bl{ZBq))yLE>RK9Q ztrL2F0R%{qWm*K6rbIKCi(BQ)Pphnk+|!&x#sSN{Bu_VPq3c=HM24j3f0D2=MHFUr z{+y!Lpq^3xhcKOq9h}asCoi#<)Lb5L&*Ups&R^(>z*d8Wrg~mvDlHVXWO$EWR!YY} zEoy!jo{WXBx1t#{F{f zO*7i`_@+IcK=gL&&g3_dFM`ZX$hpFES9xdfT*d|OBpusvLDI3&x%30gHyC`_jTW+= zCO#M!L}ct79*EPkGvF&P#J-F3pqUnMk3fBt@%Xce9~SG}5*}BZ7C&SwOdJDWIMLZ< zEv@fJUb(}1%R}uA|Gqr)wfB5;^PcDKzu%Yj)(5{j3_N)~LCj|n^PyW45d_edhuzRc zYweA_m3^yO(oh;cbx`CB2zC#OXtG5npiW-8Z~@Ne%Fw zD_z;0E6dEt!T6rDwtVF)$;atC*pET7wks1mB-a%+I^I1PZ?ord{%Y~GVzjpO(EFts zr4H2@fEFhPkm5*WsIHkVuVz=~W|V*^0TR!Y-PYp%^H&&8P$1G}~r)Q0j(ONz30?>)A-W=$oZv~tSpgSp;}j0PO_ko<-ALF0Ct1 z{+f4DMxp)?dvp$bik)2Pg7bQEX|u^-#_Tb6wrUaf8ZFeovk*>L^%{qxfg*QIOuPcj zEgrmZs1KC}g$Z>94YibGIQdJ1eaGe| zL;bVMH-vh|+8SF2M=nn+@65^Ym$i&HckLLi#oXWzQTe(r7Y8$QOJh&B16S$XMhw4P z94wwF%&=j}$vXV?&2NKt)tFf8?!6!Q&-633mg_)xGibQnbJs=V zTV||UeKi211Zr)-1iaGXTYm-lq$yM~j9`I*LURBXTTcRAO4`9?W>cXn&=rWr6R&&c zC2tDUmXy_&?4O;k?FwA|I>T8ppqt{iO{h;imA1k9@%lJyL_AH^h?v zE*ye{N{l-P<1S;&%d2-fTej#?nQ8-vimRQSvK z^p^|D8^fW2#=7}%B$R7imGGWAbM?EgS_p@N%VpD--FxL1H<$1zy)R?sE*|$XV>8lO zk1a*aK_J<2FT|DbKRP^Jvh!%p(Vcsl7HJh*yONJ2f3D&Z(A!i^ZzeNBZJJi{xK+JZ zbz`W-$y9Y?mS;Ea&j1czF0Nz7nZ|ah<2*libGP-$MDkZAcT?{VvhVk*@u>bmYCKTP z@b?Og2Q?!YA8Su6DB&J&AI2B(?4#nRryl_!~^$iO-6{X&>#-zK$HgtJ@VT0 z>aZ0Gy)i@pgDG?*l{gc};l7wS&un5;twv28N7f7aO4ovyfO*frN zs9)au?u*}dUsu+9&Ypcw*8cta>bY$8RyuSziXpPr{3^_odKcz+kr;>} zyR`C3;WJXfoec~B?wM4hY?DD_JIIW1OlDDmHkZ95k|npn3rEWb?Nj|PxZ{V2J`c+OCceN(;kc!`T3aK#p4TRXleV7 zwzAC~M#3|`KqQOUq|aLY7wK7hBmPT5K^2?KeNAQJv;W-a@qqCDOYp3wvwr#Gd-18jgB0Y6j zKS@6`Y`|CWGaLLFo5%Ju6NjlbplBg38}NdCz5*Y6=*8P;0siB+XaK$ug>=auxh+79 zlj+p5{^E;$-TA)QOmO!FeX(Z5FGIgt)EM6;-K??!AcC(1lowht>zwI&P486}pzF0~ zZHd!(*Sk24B`dME21P1mCW?f-o(;^U7C7fzc9EprzE^X`u2#~`4E`76+erMWGO{;0 zwvV1aF3|Ki?1vEShnVE{rur%)G?OB(+Ge-?kf$&OJ5hI4pzbC%EbHE+GBE>(I>0B% zwldPiTh7jb*AgJ+_*$XfE&Zw)UPD)8eeH)oTw9NHHL!ShR&e?}&ceWQgFBC4Cfema@WL&oUz)9$>9sk-|>pe9%8X2Xo=VcX`n&0o|lQZ)(N}gtVV+@KmeSQ6y7y1T6HK%T?;3|Jo;FoB))UWVhlQ*IPPf*?!6Z()ht&sH7825yYXzM==M;mywEnu13W8>zCLJHs(PSx zS(Oi*eL6YA?>Ki_<+J8^woxzopXB%9+0svv5gWz`#SfK*c(bZhpzlel71ozGSg&yV z(`VJZXcMr;rGTfOo!^%!EqY{#ff;*tCX^SSW(fu6$++ClKz`@&gN|?+*oVEIH`s5w z@nTxNv9luSAak@g+7H+QStb_3fJ4y-uDON<7GL~g{JE(gE_`50?a7wsTGgwFdHpzl z!2`j|&cU{Rkrp>{k6CeSJ*9O$Q@`w_E{2Z8Z(fEgo7#YvITf}*oyL!zN*SiYH!SI+ z=RJsb6m<}0+8$7zXVluUC#*i42X+IT?DL7D5|tWaE&WO|Mao)+8@g^Undv;TFj!ZA zKNjWtb#?1UZ|Ys_K$;$D@%%H_Q_nIdjkC@HG!Dn0xt}@30>6ZC(3Q^blQ>&|o9EpZ7|Irk8bC%3Grk-Zaj46bcE zST<8Q+Y=p*1;3|)ZvzXZqv0d@46gCquerg7mJSBjvgZrryz%CN1LNm3V5g zBBd-*`EfARDRHPV7g!=1U>qIMD1)1{223oG941w;s7lzlQKe5YmWPLkG1);4-D7?40 zWuNuJ-qvDO2m?%5$5)b{UwI1kFrS^9`z*>oo_Zettm=m{Gamx&NDT;S>KJGSy!1<< zok_@I)`oO4?>TUuzxem44S^wUA0&yH%8@tj+7PLs;I4qIQOuUt4lHvResL9?R+SVN zbs%fA{6A;+)m*$<E~Vmnj%oNMzq?&$q++Pw+|ut(>4I_57o>Gz~2yoSG4~ zmVwYxt5o64mn+Dc9-a~@`2=Bzc+;3iSHlbsW-(DXE2JaTR@u&zb(5uuf$6PV=8pW$ z(%)OxeyMNwcqB5u>zU!f?$-8|;pUNr`R6k-mX^QkuO3|O>t7pVJxCmT8P3jyrj7j* zMMWw3-+C`Id8lp+_u{QAsiNt=C+Za1Pg$S&6px_wZs1g79vuZ5Is{?!Tb{w-y+UY6 z*LWzn(j^4Go>Kb(9YfcC*k?S*b8w!BeokE9uWCZnbMOi@njjn#ybSU!j5VuZtq4o- z$tJdZOUoEc3ooZADP&zE`Kf+n>peX~q*sQ@HsHBL;?2lVi%q3Q@}eSLmMRTlwn1vm zi1oMOhQMXl#@#g|Gt0i}nvee|rDWu3&IuJvDzB@a=Tg<3x3{P1BWZI=@u6}~Ci74t zSWMxhoQA)^uE=B6G_(+?XsqvN5dyt2TBd%)X}|i_&wi=Osg-H`$c>mgbJ|#US;<_gFvKD| zjN9v(mdTcuDrWkKU@19wcQC=^x|KuPOGR$EV4+;CtR$A@fsUjH554%szzh^fmGPFk zrh1OZdOQ5a-8UB1mXy{NZo>py$`Xf}=>P1qPgaIeR8wAg&q8h6LsD1Mj`>x9KkGg3 z<6f7lx25rC+S+Qp&1^O9we?}(^=fT>kUt(z29}h4p6K|XY-RT~mW0SXxwuD8Ukl(W z+Kj!#GGQ(&mADH|`{c|_{QmndR07pL^}5$hojbn_iYhPWzJj%7*F+UNm*}m8jhmfV zv*t;&zowo2w)gx-tMls;NbG*3vc=Cp4HX8 z)|1OCH|!oM4i?SDT3TCTf30Mco$p@6vyk%6^95>wV`iJi;BM<$(X^4;4C8uq{TGtA z$`uVIKZwGYTE z&os@*GP*LC9V?}HGf!p;54^KZwi&6{M->cl4ny%zTN$5@e_E{$>g%~U*lVShinb4O zXsEB@H~pWw#tJmvjG7eH&p^&JWSWSGfEI_#UdHB;1kxv<@WK12#@vPA$pDVOko3AP z;9V4RMvqz)AJLN~-A(NjJ1B3GqTa>qsw~tA;4I{jG6=)9DC-#-9*9{T_OZV9b$%_U zxumkSJk%5{sV!cc$NLsEsj}ZYl_z%H$znW_n^RbjpO=?cn3ITG@7T4+e!IRa1t--| z=%Ck`jAI)oHUATw!0yvHsfYX&O`TY<^NcTmg@~SdKx6Dk=DV|8g zu{YXsB#*I6R?pK)xA(?0`ZQ8PZ-qwJvCAd0z{?rhEkh9r`jajMbV)`N*4yDIp=h9B zP?dD7s~+)=fJYiUH&Xbd>d0x8@oqt_m_Ln28t8~<5J!Zh@|8m-i2I=&7^ahfK{=@{ zPjNCJB*$T*sZXk#;Z^HJ!RlyTt9SK{t5!`$UPt>>pW|qF)m(pbe<2BV3$Rk{h0d!O*G}q@GuhA%%+A8@M}IzfOMi zHP*~)J{{k-EaS4vr+_D^Rpgm}^_XR*A2x@QO9H1{61b8}_WizG2aX&8bHDOO%Kf&_ zVg@wE8s9v|99iDrrgJ?rvtOpE3wj|gTuVo*T67`QYWVt5&^>MS`!Heumv!mymqGAf z|K9h$zWLHuK?C+!0-jfq=Th?pUU?nTbaBlQYH;kzC{#i%jf=x8mxUvwm7f8%(-|an zF^u!hJDri0(eqX*AiPs=ncu>POBCMU}S_A8itU# z6v&T<$f1)oM_EQB&3wH{XJgC^&y&FD*by@!q}(=AUL9=K_|6KG#C|olcicak*!aR{ z53f%7Cw6Rn{@(TF*RHL-*2+v?vuDpWn_q%8#QhHRg<3b-mS+Gk{)ugvE9(f$J$EqB zT9MKxAdMxoC1B?2o>~#ax}@wCQP(KKe~gu?13h-)1q0P&Vd6AXD_oqY9IQ%Psa#D| zmu0Cawp|y;x&C4!qk3t?E{$7XcGt!^bxtx*&gWhlpRX{VG>3#c)?SGDhz(Onbz>)# z8I=!!6g^A~>7|&Dl<<1+P8`={4ekh;*b%7Z73ph$*$@cNL*5QHsF{Uf88G*Q0s41m zlyp^0Jsdwc)iw~1|1?ycxv}TL#~#_2aqqnuwN)(qQ)@_lvO%rkn`8~~8zA^4J?Opm z6ey;e+Hug)GlLj`$uHKNW@0p&vCL|^^~_YpN;Q8x+R;YyN7NZ^E?SMG=Up2;+8!{~ zO|M;Fk3aN~yBY<8@yRC_Zc43j0==p;+f_WJ5_7aXN8m?et$Utl&;PC_-`=;W%~M<$ ztzM-gKs*dakD&7XL(3oLrp-E@vkY-yOjjxV{}g#p=%e7g2TxhexB)y>FZW%FUWQ-s z^1fmKEKdaQUxK~baB zcT*GPU5#b&+1dC557=kB4v#;3yY)q$-Wsb&zT10vYH4ZeQ90H%dD+06!dC>y03M(l zfMS8aqxO3Yi|?JjOI8&o#JpSASOBf=;>N|kQJxQiW)GtC39)i`C~7XuNE z3K3!QiTZdLnmBceF#gu2q^x9hxi!#cRQD*GR;uS~ENGyTQiB+TPnEo&CMaed>W`1q zdHb3wwzTJ7x6!*gP@R0ZxS>DVJ6bv(zGSt&ry=mtM1FK`W3;jAn(ME+IX1sB94nV_ zM9aXu^-tw(qYYVkiwQN~(`vq)D0<`}(C+C~17>uq>EiQkBt0}ry0ry?L-cqtKhC{L z&%H?>3GJSHyEFGj+^MziC^M>GZt_{K+26jr=b-<>lP}#e+9tCSp}}>R zLmcBTQrAcpXhv(?LEPDlJJ@y3uevJ8?%Shq(~SGa@rqgms=bsgCKehxzQ-D*K7?dp zA5O>|i>z8}+-gE@pGxh+De~Tlj?VS!KxgQ-GnE57`g4mjGPClsCTBzK^Ie6xZH=qz z>pKRAN*lr*$<3x%d-vMzj`Cne=6!D&J2>BDS##c9v-_H7yDGgM*UVmZ#T8fWx+#*K z@$BH9+S0vK6R&!ek})%2m;uPgLQkABrpUvPk*%|&>3eA#yI~nS>`Q|TRqdF%tvyX) zTA`@#x9IP(>R%eji8Rd4-3J%s3opE{s(vXcJYI)!jImEI3=+!3H{&;XTj1<*EpU~` z4LHK$JR1Qr;dAP~+wne+eP$6O=jY!_Q2-e#@Hp1<;&F4cv$-jPdfb>uGuFGjT-@Azhw)5M*Oa6A7-qAR30c398aG!}Fxz$@U1@pq+2@$gZt(;K zbv|GL`zW06FVX8}zEiK8l5A9+n6^kw(pJ3ksi?dG0Hvrrh!>CGhl+~yFsIaGy-YUL zkszYWDn70x{r#-adFrXeW!Ak`8@kb}zVsO1zH=MsJHdMT+qmyKwrYc&H>#zt8KZHv zVJseekVQ8Q7_1x=N})stB6l4e#H!>Q9LS83mm@|FuQCsSdjnpmtXv+vMa7Y4TyT3* zJSC-=G+Q|XTSBvu^)EjVuC|jLIU|+%xIP%ZB=PzKC0l3rNw#D1@392GkqpRvueqA^ zxgYD`K9@4}yhqYAYiB^uO#5)|Cp`=N9>Rey?qN01#oCzPb$%~6-3G(p%GmZ)jhW%Z zJ*h#Vrxg%sm)MGpyG_kMD4Wof7m;vv^g53CK(q^|PvD)z%HLbsI^7=WR_CJXoUU8# zoG*aer&~*ZXiZk|R2Fp%YRynbza&nzXAW;3|eXT{3i{a)Ly%3C#7js+aNaOvRdAbaMYc z_TD?duA=N8KQnW7Z+17?zI(T1cklMzv)S})l1(q9C)5y;R1_5~h_B{V@&aPVY`&k*%$#!X-Q6UL{_*=6xI5>bd(O-=&ph+=nE`Gy z@LZ*t=D5SXIyHV2LbVF{@OFiXPVO?1_fc6{*=gs=^7GQNbL=WDtm6{rZW(;v7H_GR z7&C9);G5y)SsGy%zm}5nnx;{`PD|77&A<=l?Hj}s*KeJz*Kf7aw>!pal%AYCruAE8 zPO9#WQK#A4#|`_KX`Q2MmNtkv=OV_DC|Ecrjq-B>xog08bqTbw>>X8TAhx>jw^ zFnL+T15&G@-C=FKgCG^vA+Ntlb26+3n9~)CD51^hVZ^Brz!8pdHfjGnZBxhLLp?(n zzS4x@b;7B)IBn&M1zX;nGx!H_=l6GBa-m+3wqSbcb{%9i}%faoJC>pUm3bzul0&s^z+xe$sdF> z5A@Iu{1iKnaeuHOW8VQsvZh#YBTmssj(x*DpmC>T-(P}WX88y&j7+1DK#HDziduwO zL_rrqv02kv%y-CgiotSKEaRoR+@wPxYH_|f6gN*=`Dr9zVgNk`9>M^6 zOyj5&I3nLMp0O&14P#5E4n}!HxC<256B^D4Z<}`ET1vGtUY(#0G7th*Q*ZS zs3}9%Z$-nm=hwEr%2kH~vcr+{(ASuAKb?~CarLC3P9lbw$%hOi$MMJ2z1Vv?6~d($ z^waU7`pP4hNe1yjcFAZ<$V=f3f85f`cW&Ic@*VFuFA)>xt&~lDOPBV26eFL63wdM^ zxS%{`oE@u+%Iqp>GW+nUh*mfT6cCPJ=s6P(yeF?{G{VE2-&cU9j6PfdR$GT254qma zi?aJ(wH(p8GcC6)FWOa;(Vkb59TBNQyUbZ5Q77VF_Lm+4M1V@f|MGDmVT zswN)KguoykpG4S6N>dV-3mm6OT#kmN?y+Hao}O_MLrov1mY z?6&30Z(Cl&F+TTPj&V)U*Mz~eq&Z1^#K3IYUqJQI~nnL`oSJ`xZ5UAP%yd6 zJSLEuPGV%5Tj0b8=*wlEHpId)dhQ)0^zrZE12uVuW_n|OSwl%dRbhT*rgyGp!aaQ^ zJWtl_JmsoYJZV!O249?!lbw^9ot3W*IoU^}Eyuf;!?QQza211hOULcKQ#U+Z1(v}v z;HuuoRXrG#as1Ow#1*f@VMn`74N5A9$HU;ayxxoPxEiLplj&YovI7K*~wn zInducQjL%2YmyDc^OuUd;lO!|%crr)2A6nZdUwD_(uVm}WzN)#*O@G92w(_oers`s z=T{X7(5(DP4B>McnGn84wsC~$102j$N($kxKPfABY(q{>CRSI9+LYYJ;yIHF8zxls zoQ$;spP1R5nGQDyIspCWOBvYa@CSYje-2{!C0ze4z6@Y^X5iWS%s?H(Ph4kfnL*G$ zD=@SgKyO$?a{!)`$mPOjK|d2DUFdGuXO!_4ofs=t1G6~z4=}+w#$*22b}rhU1Kehj zeiPS#Ch@Ky9O;GZi8flJwCjzm;d)OdXEN40V)*`VgCD*^oOZ?IT+hox2Y*a#{Q|s+ zYhAh&ZH&8tpH$bgG0c71D(1dttC;(r_3vMex5(qnefm+%^PW!>`!nb>zEbrWTmov3 z{*Dr7HZq-zT7qZ8CHV*~Ne2TGP8K6dgRRbjKt2ss@92TiFzQ5n#2krkLcVeO##Qc` zjigpIHJ9p^qf zGJE{WQ`~iHPWkl<>z1u?SFb$f$DdiY_}W`;xmIixtFOBD+N%a182mM9f_9Um?bQ2# z3&<;LU-keeT%)ct0KLepH+D%nb&`WHjDkG+b+x3!wuMYn%+nIq*y$49LZdvA(u?(+ zqX!G8cCwRWi34^W`}N{ucSh28>EPmp(WNNO+91`iC#}DNTnFEmrxf+KPF<-Nk@wVh zOe@2*7-AKpvU_`Gv4@165vLDTt8186Fbv*BaV10 zzzI%xO5$8$4KkgRTz43DkV3WGp@Nzle$cn|*70j`gRXUQR`S_Yt*w|7)Vx))o|YMJ zCM9Wp+fnmH}#@WSv-I3+WGqumWQL?`aEI1H^?{uhP_Ha-`uMt!?~FL zrT9Ffqp#5q=-UgMkZ8Hb1j`IVyjrCs@s6|)0}KHumK;>1iljcgQlzi^jJFTN9QcR; zd!lbif7d!Dd`+H?iRTtzKg?fh9~3gp(Z_VicaCY9b1etf_o0<}F%T=k16CHqY4rG& z>47p?)>QcmKqkiJgKc6tlP!ySmv_;pFU;00*mDGCG|4AX}OD+cw=A2yVtlFStSg?|6xq71W?W^K$j+)9C!hS{1BI16tEN?^myK_& zTyt{!nu|}UDLr@R&G*3eSv@X2UF-hD!Py!IJcs_B^v;d50er?h>W9Zc5aOU-;n;`< zalq&sabPycp^?O@3LsyIiLv-jOtfiETZ3=J1f4ffn36ETRIfJd(dXxpjDU;$wKEoX z%x*0_Dl!(W&A9g3NYCnX=OrUV(?tw96f;2=ort9(ha%PKb7p9nL6tkmp-9^0FRZmQ zhl0Agak?>Ck8Tp=P$=e5aK!-knl$M{CWoTj&!JFpITU<~ITSp%Lg!G7kU}wj_#6tF zIR$cN{ffD{t@&5(FYaE{onC+>iZPL?Qwm$>bfk_>X=-1!V#Ug-J(*1<^@B(1>*~51 znwlD0+EPb-@xZ#vmm`g0pbsZVE?Jr#p@SbG;7|x zS>PF1eW>@G8DsJ&;@2-E>Do!gPuRi0;JgvdWZpWDqEvL33T&E+G>RX8?ZkC6Q)Zoz z)4%qVhcNH&9{j5qC8{UQJA57e9Jh&C;tBa>P-8JDOxy0>N*^z|wZy0JBz?+)k;kR! zqj4g(8P7ai(UT2krb(g%Ry_O!q+aZi&rmWWHt~CU_mX_|PuRFARkSReGiM<`&DGV- z{N#)tor8~OY`O;e1fc7hlx60@hW^1YIG? z$rkPdRBQBs@kOPtm=HxJA&tjb^TeD;J(gE?7kp}4>Pat2r-?wIDHP7ET-f_Rlr|m5B93y zI#e#x@ON9u65ZmF!PhS>tE`MwSC1N>T2R?mK5b@C)B5j<`4cxo;-r>TR2Ajt#)`A1 zcC^fhE!24W0LK0kfVYX~4`qV!wuInKnz=T@0(cL=Nu6oeI5$vC_2I@+eB>^w%S0h3 zFSbh4I*6m!?_&uy?p5I)nV4QJZ2ExZB|4kQ?A^umCx1OLmJ^GTy4vdUvWCp&oT*(C z=eNbGx)#suotD?0*-%|qzqnOal||j+(xQT@;+n43nx=?KZE5J7+)z?gI5t+6+S?5R zxC!I(bb3W4k&|Us zXM0VGs;b6`Y)hZ`=&A`5rnr}0Ddx``_ou&g%o7v747#}!aU}_7q~?#bMacQE#`nZmCXs_~)t+3yT%qqVtDEtD%QYbOyK$H6ROjg8g#h*^tgHO!gZo)v9s>BJ6kv8g>1W_MIoBiBy^5I2*x0w3?z#H^M-HG(^Sgi=9qHu^80QZFS2hywbT309S3K#a&8B%9>;tu zhBnp7H44EQww?hQXjDnMe$XbhBEw-oe{yo;K+!U1L2Gq+ZU6M?{k7#aEeqz%#=p9HNWS`dF>7jDO>3H) z-W+QlHx7T&-L%%4wy7_TOB;v(A1p5&U0SaHXj=J@<&UHlNSXqhq09yMVS2GPd4O#z zb~MFvZN5hzpmg*U*_P~18!YmbyPf?}#P#f)yQ8#imw zjF}6rwL~L3LL^fdn+B3uyt}Fz(?|c&vh&E_#S>!RammgyKbzE!C;XQ~$xhgbx^IE( zME-+6X#bS#Og*aT#60*QCc_uSIq1{yvUBJ@j3hg`jx|ws29lHW?<4s02yi))>y2T3+TR z(!^mSXyl(y|K;Q(gl0 z#{m1$)=0IwdO%LSh^vD~5LrREg>g1k&Unz*FA#6Wx&{TNAJ4%LH1d5QBpf0bj6||9H*O+0&=b=BKuzqLv@e9341$#qyI+UcTbwuCC^(&F0U#%=9#x zqxi#d6wWy7KJNB>T?#C3U+6pniCrV2>hI);UT+qd1B+ZF(9~BpS@T5U+zYz68*nypFNg93JJj;?KpV8&P{V0V%uKvwJPpADO2*te0K3?Q zi>3O4d*-oQ6at3)YjB5AO05_NW~Vs^!S*cZn0MrP?YK$V?W3z>WtBCR)zz_z@{;!A zrrhx@+whLO$Ly0?+fs=OwG@5>WMwEo@+2Xdf_5V+N|iPZ<1DH z=hldrd-rwA+uNtAUE+bs&;O#MZ|D8@YnuHG#^gc37vuVZ%mf*mz&(OKF(^4{Gv9@XbVxXzC zs<6DORF#zHrj2@{dv9UM_^$4m8m|ulZqBu%%hv#>Vd&p3d$e^*f3&8ha80bct|T=z zqp`Ym^6bfFH8o}Uh=F-iYWjMbG7B3TT8c$}Y(hu(%#MoEjM57IhjaHcppSdek5SHa z3aNN|G|5w8O>9tw@f?$Hm>o7b;t;Ui1=!Q{>`Qw8ba9`&vTx7$!QX-(6ITyl-ci2x z7<+WQ(`(EQ5Dx>JN%D1QtTmuLdi=&%R}Zb0c72cbXgjJj_9(B6d<`?;qs!Msn{?WY zhS=DeX%i<-s~H<>m@%!aqN0qSimIv#e8fO+Q&qz#H?6L$uB!`w(%exERZYE56=T=7 zV*Tfq(r8+=RR7U*^8oNoT=1+z*rtUrv1>K3JfAU`kHcBz8uN^wVOgx!D#|QJiU1R& zdAgknwL=h`8%IEe%~HLKQM^g_!t^DhJ*qL3q&(u;(!99a(u^gekA+Exau#~ir^G5N zXOGRNTYAiPsqroSGj{1PLNxW5WGE)ZL*j%HWoXccK;<=iRAZjxxP zs;iD$ex@E>d{Ta5Tpk8J97TRwP8sw*h?rA;;=D{f$>Zgx@#%);Cw;n%Z*X%3&!w9n zKl?)Bll&WF@*v>q_vGil zYM+iIKU0q;K8b(O$Gzx>woiwVpFt0-{M5OG9BZR@D`pNRNKa8}q$llD;_3m+yW^3c zL7U+*_v9xmR85b^DnF0aK0QwPnR=Y!6Lj+c@J(DCoBSLK3q~*SRdC-NV%p`0TRRMx9SN47vSsN;%oC!*FkCbuX1$lr(jeXadHSO5NHyuUc^eeZYH zKYxh&PxRi`zw`YiaqoM-57+)l-uuI}pZt4#{oe0ce*aF0Yv22Qxb{zuYoGXA8uz~U z`*8R@#lAmW{inv&pNyaQ-;>)<{(X5IeBSTF!FL+l$DFTlUX@eC&!C5tK~uz9^T(o*t++SA0x zzi{Z$M-Lr(Z+Uff`FpP!*WEqtnnQ=4!43+Liv{TCMDa2@*MaRZ7I4*Kt+UWsq}fv~ zfT#ixEIVyE)9-OV*Jt%RL62wh#!N@|*g@}*dxhThX2IbDlBcj|YWaWz4|*N2v7YM}a{Y0vg+o{Q)C{sJT!Be$Tw7kEKtSG({+ zUs%hIBqHFeLg+)Ix`z+17!jZmd`4;A=x8)npOsr!R$O0IT%A#v7R{-_*Rdt_A8o9y zZRDpgC#Mjf!M@sB{QqiUZZy`Ao$b1*wb9Dz-0bM+teUJGHzlRIxU!*d@}$1LNt633 z>MP+Q(0`U}J~S|J2nYeaaUACBacIFfyqD)_PIvlrhwITjjsnYZKK7f5?B)>m>v^L- z5&O+}DZoBGa`d2tu}>VgXnZ#KjbM#2uKDhGlv9osx4p1HX@f>>@^i1hx!8#K{Cr*C z$G+7R&6oK__0uLb<>$%#g8IoLqx1XEJX2BeOgsLazq`4mrFl1SSna$H9CFU)fryb? zndwI!xib+b9?M|Yc$1{MF)G&lIN+hY#N@D8c0bEhLT7F61^iP;3mMg40sDi#t5Gx9t`cV zIXdF_KJ$kw^cAVDQD4<1U?)2K1hwR4uyjSjEPMl4vp8~xm~0OdZ=7o`=;U*9ro~k= zc4hqr-ALZ9tW3}&u2{xw+3VxVF186M#-6*QDTZSm;EE1~OjK}meDOW=a*hv9LGx*H{EiM9nu~G*Ua2!{D$KZGx zucuH9or$0`EQx290WI2)Mu6Y26g7V8RO|N?$ggA)BK_oCqmZF7LuVFgkH2_)&ro2@ z|Gmln-i)#XpC&vzj#P*PkgI#UqbRz4Dm3y#pG406aj{!hFEcSAZKsRwNVNLKtiG(; z$>XN1mPK8UG}hNQR>UGzDOouMd3gmnSt(VK*zM7}I#Jx-G;2=N>wSHP4n4Jb!}KYW zr&mQs=M-wx6=r7?Rqd)-aAdd^;7jIV;Ur`;j7a)j^S4`w>h{i&(TMyHJG?elxCr$Y1qo!E2+bHJd#qlyP#@* zSu7Vqx3YBE?5e6*X)IRyQOzP3K&|;%Wm$f4icBdfE~_i698-D1*%hNJ7F=c6q^I9p z>AWgl1T7jo_B*#70Kl`IIU3|@fJZE{BotYExuvLf_%;Mid z;KA@XgDnp$q|7ehVxoSYe1~{D0BPUl)ZvPeo^XcT5coM3{pQMKl)W;NGBXSs-#wx- z!+9>9%beH6D^}i~cK`&GZ$n7g=xVypGi_39fX84?(>IeAn$O(hD&;vdpN*VVxWggh zFO;%|N;$*%OD1Nm(Tll-I`v|sfE+X$*<%n1;zX@wX|V~IuLaC60A_{>H*se(vpSvO zYA_8S<|NI)5(8$ACkr{Aseps>KLW_84N&Dsw^}WaA|p>SoEg;YQ}?yCr%m6#;iQvZ z2a%FmHv_Ic7ME!VRh`IifOqEmC{&;6!(|g<0$c`gAtC^17zrYKC3M761Z5VW16+i} zSI9g}ePU8$Nsi1et)KK2EkE}yU%u~2p~xEP1n ziJ+wZ3AlhAGs1*49gq@_85UB*zq4_d@N=`%kpAT3@hT$mZi7jp(cn@!7h}%<0&~6u zT(raa1Q%?0s~L(=9hn}>AO|l=>eP~P)`l2o7|nVch8jLmW3_E=)Y8TqZRQ4(-LWDE z3J)1XzP_SSLqqNWK=L+E>;Mv~^t~2>s+jSFYhY&cIR%TT$yZBIDWl>$qhV`^T&I8_l4!jv#5bJgtd>e!+ z9r4C;icgJpk=anx=#4HlK4bX}WqWY!Yf)`3%4U?wmLEp`Sh(KsR^5{$F~3AhZe*Kd z2(41j5#sIp7H>G0p~|PWD&0LV5}$;vCX!6kNK|I&P|ItQl9@3hKDKct9Sc4&-Lxe~aXcah&uDMIyuE#7yt!jPHx~Z`S1zW&>#`U8mwVY30#)DR zS|hW<-8elIK&A78EP-Xzr?w>CmxNFomI>(5rmh+Mah6*BJ-A`l?&upSz*j_Lj#>~o zCUtOc0vF+`R8Q6llx8Qr3QXX!`Z=!jp@ylYctJjew+)dLP^}5Gf1KCEjbf%178Dc~ zKLA;vH5&1=bN7|kRMuD4e7fAqg$34Lsf0c~8}vn-H9Nh|cj=><;7rtvl)f3_j3sf# zGFX?Bq{G<$1}N$#5q5`&&@#gSMM*eo;VUhc_o3g$@+H$V(wVrRR%EbOtFKycuT5Wd z1HcYgIc+F=D8IYA67V$w2P9cC83qUqA@S8AS>VP=vOPL(VrK}mRYUMWx8mVR1%28b zrs?xlInBp)zw>S4+S3<@!u7=A=`#t}Q;4`JAzUY+Hi=-Z3O_7;4u$Jh;ChzEbsGp` z3cg`ik|8N`1WY6TW%rxq0u-B!kDQ;QqW#c~{n=PJo0Aett%+6EltnwvKH3OW&(1E( zaT^M9Mn|ja%gdVbnJ`3NK_9F~_p6-$;aIG8)(lJc3yxzfCUGn#g~lQY^+|&>m#@q< zbUzfY7-}pg0sFMi4>&7O&efo=0N0@PegF$`9 zUFnvURMeGZ7i8u($odMX+3`GVJTU(XM{}|>W!TXq@p8rm-j^f^kTkTJ4M=ptZQZA%&x{N6phL`b4uyOcc`lxmJab!ZF%2aqM_aJ+yzqp@;Fp)~P= zQQ!{PjT77HCazEojMF{Has;1ljChTOh&?Hm zl|<}zb!5OdtnH+43}tR}x8PFn4jy4)R*!LajTr9M06=b?Vf0eQDB5v{6Sm7(N5h)m zVOA2N|FehGKED@YE7y6XKa~1uILt}fvkr2HFNi*u7d5?)<=)Hr=!9`Ck{<`>uOvxB zyPz&0;3PC!64zLZr&r^Z3Fwv9CX!J>ms#)xn_V5UfI%+d;|>l4Wi0UpTQGa5jfe z%&|`ZD~UGvP<*xv&l$^mJOC!g?Zur{8lQ_%8u#;J%=6Xy-ZYepbw}smIim+2o1LDN z2rwBtN9Z7rj&>(Ikw@g#m-%@E-$~{ZBQx$d!s!J=a}oNO7^m#RIA=hETIl?i``;|G zU8fU>e4J$v=T4$>yHFsY@}x0M8bZ62I^Z6i9MeuSrpGo9ObjU(qh)e3$UW#-tknnI zkb8Tp%Q({0^mCoKnvJXYSrR=K|BLU>O7;R8UVhm~Zms)=GE9t-XbX*>*Lab@C9%tK>tC!LjJwU!vh z_A%jFyODo25?QH=B& z3vsDR-l#M;n8+B_jh<^Q%5G62e7xX5sWn z7a~NO9j&jdt86OHFC2T`Q3e2XsvIxg7lCCxiKEc#+nBu=1qqz645Vl~G1(rH2zHC@ zfWL%2``U<^z*i=OM#H9+>hWp^PAMV|p!s&EO*8dLVD!eZ$Sp^VW3dERCLWN7l8Zcf zT5^dFxgLPbia3+ZC>W=I!7(8lS<9iwz_W+}j2QbH{7Vob5Zf6KxO7AscY|mF^^{93 zz2^Yj+ZizKbtdTkQb`D6LrIFZMOT1f8%zmq*yR_&1hA4_-{A5KD%zA3USPTgS5%M9 zx-u^FWt^qPTfUYgC3Rx;(Ne|k^`)U0JtDRKEG+xp7!ieDoaH60>$C%9$d0J;y}2f0 z09^+d?C?28l3r6s3WU|}CmV*DsHV~5w+?x>aGhNY(*}a=&?v40Vta7~u6itT3+kX& zKM>W&m3BRi96msZDpuaa)jT7s7bLyPx^)+WNHNb%9D(_n^ofrJ0^z4Efhefa^j8Ax zspk;O*K}yg;6!1Is{naP=uJ{Fj_wn;+p-&q1W>fpK-@a`_a5LlYy*IQj_9Lt^U;8o;0l#!4lf z8n5U?>BX4`y?Iel-L`1K!ju&sojZ7NwfJ@6(?9;P_(1XDWr*B&Ia%0i_5tW}1zh0h z@71VKX9{hDJk?t&qAD+vA-cZvo#~Gsn6Cf!iUv{BF!+YZY8d?S;7bi6YY?ZYV82)A zI-J+m0tOjn_L@C&>@k2WVMV*m04ka#qP;k!sXkI6BK5CytnL!0i=~5~I$d2Jz>ZS$jTM>t+|}0tr|V5NQ{YD7HfEtI2&4nZP_w z`sxr%=ZP8Ti(0@r7Il_+b%w@t3~IzFwSbc=RqIhB5D>wRIcsK%q?7J~NVHb~(kSfE%E_!!gGsmsUliBG!A*R|!RZ8b3e*XHgF3<5qP>6; z1Xz!4PW~DP)j&cE8lR0oTq@`x+i4(CEhY z(*G4p;s^Aw9ez_hZK+(q(j1{3OVXgbX0#-ZIeMG`ZTq8>%;emrM@xj>o>4 znOA1O-81-{t#+K5eMNq?pp_ETJK3u@goglLQ!k*cv@`lc2HfYwp169S?;kwq&1&H+ z$2jurAWbjarki@qQ1p@p#F$Ag^dtwhAjg`fd*v9@G)NPD7_qG| z%>->#8ioQcg^8e9ifL=`Esr>LOLEL$uWbqIZ1Ru%34H2FtP^=3+PxJkY(9X!b2sVz zbNO4p{_O*JlE2{@{s#CQ@VB+#Z^fjwC{SKXTs(#>qBCrq2>^w4t2q#(T$EKlt-O}n~(rQ;TNwJscA z-d|ADRaVoTu`Up{_eYuDm&rC}KEicM)RE2!_O%*g0i z(pyp2x2o;#0ET>LF=%8vG(6xTW;HzE0eg*+f)1*c~4_fC!>t@I(tt04;UR}~r zQ&?EjQPQ+vVrAvT1x;6*CdBVGfSF@kD=J#YX7)$h=d`uYZjV~3piUBMUj*%whm>j@ z7&*&b&UNPtfC%n&J{fXCPm;X`0Iy*W5DUO!-F_5pw#{je_IrT%?U!I*Uhena;Vh#o z*5t8yJi%(R0ZoRtO{NDI(hHg;PpOUJ>HrKp0eCnOKG!8>6ZukQ&eT)__?P{ zp1pfTTl2DW=B~f3wX^Ah6%$YHtE%fid3@`#NGVTuEqxcQNCcN$#ITImq|0KB=hC6-&( zv#7moQE&O|nRN?JZd|Z&%Cv#G^(Boxr43^0ZV?l=?wKfRqMeiGwT?fzzpkq9HK(z`~W3&kcnNuZiKfF;a z!IR?1&z*yKZm0iTTGDenar}c2>4SHPCF&782c28N5aLE#4ttKBV<;)H0h>tg?^lof z{Z-=kAfDej!t>Zmy87V${pN90SP6K0HN4E;pUad@qt}~MO!`llk_rDw&>L__+;Z^~ zBe%>xbx|^sYaZ457!vD|pNmiBl~+Ec|3}aHk9b16=zp*7|FN`du=CHhyK_JPv0WGV z{1NbZ2_Rs*S)64N2w-EN5q#Q_EWPezJqK)4VN=Z}XO+7fr{IB9lxg^+E@o54@yd!C3tWWA(0oBP=|FA`A;g-u&}Gx&K9wR32zx zwoe24q!gOj1}9utK`LNLZ#_%k2*KPP!ra)fSO1} zJXzZ8mmN^k7~CT@k*ac0<|I%RM>L+Y`upXL-{`}CQ@-Io{|&~_4+6%W|6v%z)aJqH zQ{4Xj@gN%Brg>$GQ%c^Jsy7ybtV?MRs0;In)&*vf*f|I*VoCquTP$DH{B!7cJ#HHR z?#}&(u{W9Kk;l+(o^JP`xN-2$`)#xSyE}p5p4yE~7$-5d80{yDzgotT3| z!<^Wze%EUkLk3w0S)|{^F@E0oI<~*o$H4R4Acp!y%is;_k;Au8K8ke8$6?>4k)rLy zgm({6W;zFtsa;4yw0m$rrt@~&EZ;q*b`#!Zvm7Jzn6f$%S2A&Y(Q=h&=^wm7ERkm& z#xWvV9~j}?5T1i~cgi`3kvM7ErOZFJcGLF{ZjWzw_;(ZA)$e-kQa90K6mcpjBC)$N zbrVQw_yZ$DjIq{KFv`zETfs16B5VC-$T}kQQ|db6$2zW#1Dw!yKBNC>PIH{U(}TkW zwUAx{Jtd6}Pd)zhyl5JDzQ4*sg$Wm~vz|@(K2!;~(f}9zZmwUaF!k9Q4_1+u$u%&# zrk~WyhU}*YBj>-NX%8+9)t{lMnJVI78m^xPSI5v#Vh+q<_Ba;-0dBxc7M_^jR~TG`o~%{ry#rpW&B7)RAKQ#jRt8 zt^=Jx9l(vQgL5Oz0S$!w=!Apx&7QChhFt*}XaM||@MwtA_g7hv4zE0X3%R(St5!eP zbtceNe4R$ewsrcwtkT?>bY&z%xX$CjSNeWiQ3zkAuPt$X)v*F=cVZ_}M&i<7Vp2Qi zBpRZ_nmuUh3d5G>AVL@FZ$iD8>`WK6fpFar*a;f)8;Qq&rnoryrpLO5Czb?!h*gGR z2QeeCWnl&jHc0RW+#4UQ8z7!MY+cyph8uU%)J_E+A{q}=Qb36EcCa@JZm_q|m}^}n z&6$;qTf34jY3Cf^fc6Fv&W32+6C(s)`&*5i*W47%?W|&ZkoFemw!P`(%RcW4<2-23 z0-$kj2G_L3!;uwFVz)X)i&ntcwJI`?fSz?*BjHO|4VUpq_@a^&5C0O(^M85_{;a9{ zKQab4LJsQY{sUu>uC-C>XK(Wum=1);!0J#DGjE5SQvnUxd8;P|JY^VYGbz9gK|j=W zL_rVPD)-a9gDG=rhq58w~!L;JFGx$98D)NV0`m5}G@Y}-tLtcg# z4-Hoxv}GBF?i^EX$TYn9XXb=yugCUl@CMEa^z6X?g!V?lUC>^b=b5&K#GU4NzMg4y z3C&ag#j#0;2K=8G8@4m}XXu{)p|R2JZRcG1_l=FkU3hG)o|nP%MnW<2FkO!{Z@8o{ z!xO9l@1;U%7sQaO?w%XWuC9l^G^q) zY~}gza|y@#d7^lTHqDS3DHCpUc$_Bn5jVWKdo-My;Y*M|8mGgzcl=|h>G3FIi1rfq z5f7s<%&kOtxQS8{CQ z+aIp)0WsSw5-gKFJ`=)n zSa0(A|J%!tbq|e}rd6B&1V0Yi9F89&ZXS|3o^J&51f)*8m>z7#iPBMqwVLp(rKTPp zHR&vb?m5QBBhZ4h79GtYRx?8E!NeA7;U8c4{`ZR(d>C^=ng!M#97LUrw?!E3AF@v4 z5g=I`s7-V$xlV=xP^U;gay^ubr*)mRl!itV1B|EuIr22V=;`Og52AILCN?G{pB_Af z{}8v6&Oy|*^SJ7Q|Aa>tGnxnAx?hze5nFr`Hrb6Du8O$ml@}rAx>xGyo;3;GnXyS7 zpS)3Hj4(Y3U&W1(gTz_u5IFPt6^t_nC>8vfu<`R`fB$|Hn@q{q@kuZz_`v{M8fy&c z;%OL^Aj*~uCq4Ow&6!Am)`tXPJ&b6YAgri&3F-y9*RZBQy8^dL^&_KZC=(m>pyrr z{i5YyKj5bqM|Z*VS$Vh#*+dbv!k8&r;fW7{M*touYo+&iRyv&lbU+~LcsN_ucs`gL zJ=}!+0rCDOFZdbqWm()*iO&Kx2Tf2wtDc}S(gt9)xbZYWgJPGN%pB2F{B9y$JfDf; zi?uU2Mj?XS7NY|~hHC*<&6Z%jwV<&Z0sVtC;L!yg4**Sr7B$^qp_*^Q=@D>sD-?~0 zxT6(s1ay4dQjUo*JP*RT0Y252G?jyHtevarhFN(wSC+nK`uB|A*y9Kx`lh&p;01;E z9~@jnFRj+HG~ICi0al9}@1#=0zsyPmK&{YN;)oiD64$+$ku^S2{M9YN0kdd7T6-R? zt?ZYtqZ7mp=ev#_XxxP8CiU3p24Wi)I!H!GBtSQ^<}kL)MZK76oc|g($iVk-L%(p~ z`%J`*(wLz#P{kQFxX76Oz|Km;n{=HPH=aZ`EJUv%7^R`c$zh;&MvfVLrUnS^fRW|8 zT90$F4WoAGXxXBJn(pO;hqJ$fE?^3W;tLs0=K%oi=Pc9jo^i~y<3}Ad+p**W#$$Pe z=8+jrFL|VwJwZ~jV-1Q2#=LC7c=@8agW;Bp>KcxD@Sx7;NXfUZf6$8NZ@Qf{leLiU zN9ZYBi6oY3dJO^krqH(xv`H*_BgDlh;8qrk)MUv|7|!=OwpJwrI7k#eESxgvv(_aw zy=k0=uHzH51zL2bz6-kNKhoI?}W@KPAau>-l_ z$xL8Y-p)A=(ZD_+C;=T`U+VfYqbnFxm6S#JFR5ZST*W_rxRiRkfyZP%$uiFQ7ui;5s95{F}jo=7Pn}V^TwlI zGn&M(=hZYy6UNbq6vH>Ijnf@aFK|n~iF!~obz^T<8ku5xI*L8>_$TI~=AZ{X@0<}Z zz;8&tX#Bc9e$5Oql_5)qZPMgUl)_${gdvzUX3eXiT@6JgW)Gl9VQ)3r?xge?yut4> z$J}wfz>s6-PBLF$+vGq@EFvsSlOeB1)-T51jI-qzR*HxDh7nqZ zxQ*B{EJ~Aj96Yf7v*O|5STMJEq_N=1R)oU9$9+h&$sLT3)sGB^=?B-=Q^CvwGp)^( za9lIrK!22bIY@9mk85g+^w#KxiMVKn%!H_5nQKN5?H~6D=4kAV)|Ht3QwmUN7QRP4 z`tnDXy04u<;StsZ2Y;4I6uGFxxQ)IjT54 zoGHfzIU=3!S5>hpyndRlmrbxrGY_?#(shmr>dZu)%;&Xh4RrD_szkzQPPbSRf>EyZ zp%kZ~O6n6cO>4RhFaR&7mfRqmQ%i$K_X8P4OSJDGu^&MW0p$Vv!K_`p!yKez%fn?8 z+P?Affzd`>KfE?V{m_l*+zm)O<{E=;hUf<^aCiqyH#i>{2SYz1P9Vw6rpDG^YyZRaDnuKRy)$3=j87%Ui5iJSNcyJ-Y8iq?eJKS^6{^JKB zxa2v*JSWfjM`!3cl|RgLD*vtP!p{wlH+q0Pe2mff1>O35Apz544!`N;v;#Ndt4DcW ze06YI`1yTDdw$>G)#2w`j`lnVF7&+f@<`8v(qF~{q5eAWKg#p(#{=Q#7aryLg?PX| zkGTLk^q%*5M}SWOD#A4Od*C=tM-C4DuPjnO1%*ek$fX@?Eazg!!gbCTXCL=y47?-n1x8Fin;+1K9`Kfo@` zzZZW|A3h+?5q}kb<8%|}K%l@-(k0XoUE;tSUDntVhmPp)Gi^D~mM7VAvn{vU@?u+F zW6K+Cd6z9eZOhNv@=05M-?$y7}#jlI?y_@js z2lc&bCqp#qd!NU1Pw1;7c#i5Gk3Y{4Tl5tSCh;(?JO_g@;HZXD-7iH+bFva;J<4vB ztth!g9luYv-)Ey-i0^AqE<<^${l39|2VFQjaQ_07dr@9uzh7m)UyJf4+`j|m2T|T* zzdvBVgHD{!+FzenwN z9OEIXalaj9Bg!88eX{*N1La)YUx9Kl$`kGPb@uxJ$}PCR59K*1FG9HwX0?Kco{1VD<+wb3lFc#y+n@thT9tMnMerca9_gpgxebKnQ&XfJ@cQeM?6=Ml~usc zQ%M(MLS{P8Hc!udD(ONT^d2AF>0S9X?DQTV^soFHM9h2qIA2`rz2`S0Zt!jm{?cnP zXy*p+Rx*4+%gOKsAP128E&iKdRF9aU@Ve!fOUy8MJ@U&HZ`1P&{>}COyWtNG|oy^T7}V^{?m(W|N-!@eyMZ2m~~bF{*nBhdk zWPNott}fG8e}anfn!dUhR}bl{0bH%qS2?)in)Sz=r$G;2(^r?^>H>W=8&}iybKgUK z-^P{MO*jkUT;Wrhjh!!1PDj~|vL2t=xSoiz6`wV@-hgr;KBwY(GsrbQnAU@*2Ls zfct+!`7@L+pnL`8&rtsFe^rzr)w8hr@&8#B<%<>3Xazq{C0&SVo{aQmw0|Lfs7FyHL`7w@SKkp}^IU?z>ge z;oYHT!rTJnDCmeNzZu;`p)7^$=l4pK^(ebhwxXP9zfZT{XQNz*`)g1xLwTzGzQKN{ z{;~u2FF?5$l;6`&hP4@^-MohTKf~{T`mc8L|6lFq?)Ivxc7C2px)5_c@#>3r|3du4dwg)G zcjedcPVc=yjR;=M@g6_U7w3EL`OSzMyjz2>c`XL*_^Qu4y{G&agO+_r{r3Wpg%9a} z>P7X4843?zznnar7W3cdHI9bEkB#lAd>f03kBKq|&O}cc6cyMs@F+1W#L(mzFUqCk zqYPm|4kBjApl-rGSd4<2d>}?ag^7uI&xc~;UNkp|iG|{(Y{8484HK6%Q8e9#iP_?L zoLIv+rk{V|#hfJp)iGy@-N^`{iNl!~GV73j9n7(BWVV>MhQQ5c=v-%Wgmw#pTol+1 z2sJfIufrZlsL;ldJ(&DGb(kK&95BYgab@G+9!zpAg6T{uK6i+I`FqWH@k9a%Eb2O3 zzh7P*$Z5PrztOP@e1(o#na<)Q5Iw&KKvZ|!}kH`YZz4(&gF8WcwYV(+d^JKq}jb8FGA?8y=aC)z!VRiiVMB# zFfX#aJ12M-9lSUPKP}Z4$U1n5Dafh%L|oLA)?UOsQXU2-qM_(}u12y@UCpL(oGP6rFD>GYT;V<&6tO%kL?2doIak^7p zBp#Jt!4UPcKbFqm#cK!Rzl=M21egn> z@@(-{;C&4IomlyjfekIo^c2d#JkKF*M^K-at&IS*&U7t+wwPB{7pflzg@nF6TTxO{ zfzNHWlxLUZXXckA{2`xtSS}EU6mq=K&IDr0<1-5FV{VbK!&KZ3Xf(z=;&lm1J@(J7 z?~T6Fv1;~seB^?{`z!Cj2M=Q`j>!4qLkgMuZ7lx9E1+or-+;YX7M(i)1sYFXdZ*5_uyI^!~UNk4WC@*((L3gCE zwpXqwZ|#W|R^^S&O-suin^#pB?P)DXAFq%r#1GJJjuT_M=5+G-cDYE!G*`!sPc>8G zZswVgzTJIAW9Kzw#72*fWi-qiThu34w02C4p6yEMo*kXo(Mp|;wPK-aD0~`1yp2e;#2St{W#elapEw}%y_s`A594!nt zxfRVtURhdEQHqc3%c*E8E^eyG@ygD!lERWQ{RhoqupgD<0rTUy;%SO!QW$!tDUyb` zB!FcRd!Xs=1X7uy2Yb=(-Feo#*r6|QW(>NLX}a)~k^x;q$6#Z*$e+wKru>TO&& zWwX~Eue9A#Z6$kZ2k-Fu^3#(3F1aL zUzA7%mA}UO(>O1DUMx8OPQU{i4?DaIZceN3T<=|E>I+P5Y#jR5)W{}TkD7(^!y}#I zUZiiAI^&o%Z8#_Jn~hAE{Fi1#bZ&PA9%#o$oys(AuutF;0|sKyXMbqw#D=h-)b2>s zBtT?JF`=49sw#v&dtIw@yc(ydNF(W4nC!D6b>zAq2s#rlRKOL~_ z11vK@v4sxy8p7#2`os~=i_l&}In%K9K#p!3r?}vFxn7JPx244aB)Aa2Zks-7V(N@Y zeQPweB)g)ZA-(n4hK8D{rCE`@qMFkDNI`ZPG~?c0 zd69E1WM0Hw>fie+p6i30inz=Cdt2n$&IcfKBJOhk-hR2j`6}c<#9iUvdtT0Wc7g9k z+?D>l8*r~p-&^J1J6)dO+y(v`aaV`#t#R%GFO0ZrLibkSo`ZX9{d*_L70!M7-iiLb zYh|Ca7xO>jo@DMJNA*ED!&!$mGMx3m>L^mqbC6Yy+z%t)axwO_9|gW-slgsFT)~Z;6y`G= zTcAo%cYZ2U*n7Gc7uTJ0AgBj<~1!_}Ft~z4M+U_h34WVZAt~ zMQNrRfuiyrDk>+NHGu1mlsO+c@@>eZDm<4;yir*em<=x4mN*(QkZs&28?vp-h_)V2 z+OQw%j?|%^AAqpyOg$;Oo@~@5QI`YcQOHnn5!6!ZGQ8}N@wDc zFQSL+V+4H!=op`ZI960bd%oZPCgVI(~zNE!+k_BS6RM;=VUO7&aIRlVVN!-RTl z0bfwmSXtRvRM0-ExPC%K=c+k!eraQ8VKi2QQ<-AXqOOMG3!?W|LGJx??MF#DiR~*W z4WfQReetNE{YbQa!mV>wb;|k04P8ZE`-Pp2rJ|(j{;1#n`RIQIPI5ZN{*MI}m1Fb^ zH2Eb%5}HdxL3HtHc7AOd0im&L_fKqeE0718!itZ39gPAKMJrU;s?`YM1ao4bXk)C zjoO6CvO54ExY9IwI(DXZ^;o6jyeuoJtbp=F5!oeI4}w_&D3i8Q4BR%_ zx}X}AW2UTbX}V_|_&ls_XVpa)#dfyUjjsh8%g=8vD{IcrZ;wQ4$II_+s#>$AYEw~R zWn*r%tUNcjyeyjASXrpy8vKWx4H_Z6j)Vh>zGNJvYvT;I3Q&OmDUJsJfnoTLL4wA9 zvyTJ`dYtVXR2O3ss6&Ro6S52pAiPJU1F z<86#ZCMQmaYuSZIfU`uqArK#H=Z7VV1F#q zFaK#p{yAQpiYiaX1)UwN5-vDx>W62M?|xI6A@>{oZz>;ah%QDurOuim{BKqZLe*?B z+z{YcYVIoMPF~oi@rLFio(QB05e6lGLMM9aRDLjjgDX_8eRY3?ukN$JoZhQwIA5Kw zs2c2m$rx22-bMFE&qYW7+?(*veGc`GtZ{d4NZ zz(4ml__FQ=zX5X&`OUPJhLmT+xaHKnzVQOToD!)2lc*oN^@Qrz`u*v8=rEWG zXv3X5^-uK6`8YJ*IPLjU!fyie;ob5u+PEKI(!@djEC;p94TN@v5+zB+W?%$*8cuzAgv*YPfKLPo7G z`gma+u)KJCJ$S$Kyjxxw=jEdR?w8_DcOA%s{=4-@_unC%m|&+sBX`j&?7d_Yx85*WIM{`E#~=E<3OETz4<#ol`t`tJfK?40nn?lixe*Ft4yn6(qm*MA_2@tEZi) zF>f6x>!1?ZzKlX&=s*dyqT&1@>RwO(!HB|kebf1v+YUNl{+i{QNwoIQn#^wwZ89Hc zyT@-Tzd5w2?6+G3+*ZIE#SRrluNrMg`8ha4C=Uv?k~a$c!_zV2u^CM?+*kSL@aiFq zNf!ok;=#m=FW@9ODxTE%8ekdF5-|$Vu_zSr z@khwDK&^wu94LH|#C{F0?VUYDKh*=ts^6o@JvO%A5$ATd8}pX7@oe;3OAR9|G{?0Q z@=h~f6;uV@dArSKwQB^kS=-G=oIBl4$ROIygj3ti&uP0^!$|`g7k7GhPS+RzvPn(b zG0CJ>myKvrYoEivhS#^t+;($DFtk&<`xBuYW z;f}=^4rOnTq@Q54{}p>%U2qh8`!&!O=fFrf_^0e`jsAFh`!(mF+o0|3xuD`~?3t2* z91?_en>86*%lkQNdDEh%^-Y_b*0*P42ln`}S=(-d$ND^Lo3=J+RJ~&7{!DM~Cuod- zDt^kp-PZRj!&%>kC%*=sT&L~tGXddH_BSAW4#xA*4ROPwFC5Vje>+TXw=S9gdZssg z>wh^?IPmHmHFuDokr#GTe3H!Di;g0mKnU>nlVE(`3pth3FtR zDvCs>!8fLpIU)#>Cq#~!Y=m^9Hw$XV=FFQ~-d0(VIwhsPtgNQ3dq%9Nu&@Z93&s1y zeS>SORbEB&*zvP+#@6H&mey8|9aUQ|H(XbglaW(ch(AT>pHnBEfmLDtAt-LI1?<#T zd(C}{CfRFZW`@0XL(in(dY!$WiqYC?uOrAq-)FB!;r>(hI?d^JeqgUNoMM4Mkp^M3 zlPfCh^%&;{(P^(Uof5$u8{u*{nv=wf_8RAT${u@-v$*96_F6epkr!d zsZODM*j`5vSA5!Dk8%nX{S9m@%{fz_)yV4%r%Q1U6<&{aYTRObJx0XbsrEY48JFU< zo9FCFd9S&T&ef!**lU67Mtd!ty41z?S~*pzyY02>j7z=QUZ*%ssTMZisU!7Gdq3hd zMXK%fC}&(`@vI#?&)cg*sLA9cDBW4Y~32uKkSa}8rVIs>)e5j zZTxlV)`54#7Vp@;WBI_Qy<6As>T2ug=$Xu4_=msp$5#96>VaLmH}BXU(@%wd_iDN? zwt07KeQeLJ^&1Dat>3jJw&P46h88ruY4h$q1G@$`#x`${ZCk(nyx7@$cWvIiar1^f zsC9R2-;P~dH}2cKaiDGHj;$LB*2eYcE#ACkAU5NSGd6GE(h^&`e(%=Ux$C#bHV*8H zEn2^RJ8o?rK%M&L@*TUkt=~GZ{j5EEc1~(<-?wjH+Zm{6o35vA!;WpCA2y${jdxa_ z1&HVD*uE#WV#k?#_O0JFz%FdvykTJbZos*BI~&?_)}*0%Y7gA3U2uDK!ucPALwcsO7$u+Dh3DFEJp+?) zE6U)F-TK>rv)dWK-E;AGBcAl$U5cj%oOj@v#i(UFKFjfa6BdnZMJRk1T5Cgl9ZnCn zYVqFjuDo}>H(L|lU9DT(4cJ*TA@}MsH6_09)$iT^1m4*UxMP4MhQ9AYxe;}4(~xb! zy&cY(`0Ksr;h_chcIjU5?L8U__L{X34!*w~SdBSnqo2goZv46#M$I1lC9G_7AFg%* z3mb8@S-0B;eDBbAJZx=5P3Hk~o8kN(z&kUXGw>f<*#c1#g9mTD#$*h5XPYry&n}dU z@SS(eQ=9R&firk=IojH-VdV&I*Q37&qdW;FbUR?;e{E>ZK(r0zcGOOI+R&37c$4&! z{KjUyv(0~IC7v=ko}+6Zg{{EVnRs&_Y9k)KQP_$nH)vQ$OT_11-6rXZ<8zjNb_K>` z5x$=QSP6H`32I&xR|ET=NF$wqjGvejKo;Mw2hDno)6JSr&%j^8$&n%)hGOPmJeKL} zJ-}Kl0Ux{37moc-ys;ZCl1{gxXS ztA>E%!a6xx#0cve`5D5&dB0naB}aHW+S~?;F=KqbF3FSll$lLKKd~N97>Xv8ykftI zmw2eimsl$)b*OjZYiAEO0j8OHR&Bx?96^J%S-=Uuk^hlSiCd24HncEQ%|k)MUM0hA zW)ryvvB+5#(_^ti_iH!waHB{a+2c&{bt!X8|8+mly8`>QJN-hzI-i4GujXNzED)|p z!5IOIz(STdCqT|G1;H-I1YGH?!qiyftaVOwPI69mPQgwEHSioF>Cb6!8W9<92J3*D z3PPng=z?Z74orGH1j7VGYbSwSPk~=95Bq10f={3Tz9J-z!A?LZ%NZ-uuuouy7%j$# zOpztBMUKeDK3n<~MhbFg{YF0ote5qrhCVxM@2 zI8U7K{7qaSE)*Aui?Qd~yTm2pQgNBM9DAef5m$&S!6f#HtDL=JzqndlgFVsSBd!(K ziR;A;;=R}<|0dW2H;eaUr?gv~SHuU!t>QLuyZDg!u((6qDLx|Z5_gM_ihIPzkmdLe zai93ObDp?gJb=9_E))+s=ZlBLC&VY6kBCn>7l}`c&xnV`XT|5lBjWP{YYxQ$@tE^z z@wj+Gd{KPK`5)&W;>+SI;;Z6o&eh`U;v3>g?9BF*cv^hR`9J4k@on)PBtBmvzU#bG zJR_bJ&x!Ae?_=k-=fw}jkHib&$KogAr{YENlK7eUIrek=rFhwSRs701AYKu_7XKrD zBYx}rRvZ-n>pU%fCw}jI(>X)D>O3S4iT@L?IUB_5&L_kh;t%3Y@kj9|@n`WDZUiU( z4qM_a=dg3c*(m-Y4vRr?#Q9%rt}mPcDW!s4@-6Ag6zAJARYv3}nI_X^h8!)&$V{2# z{8eVl9GNTgWWFqLz9S1|)cLOSjPrL{QcjQ);-PLWe3 zlBZ>#oFQjApOUj=znm@SIKOt@l5^!eIbSZ23*{oYST2z#$fekJ?RmLOE_Z(DoF!K{ zKawluD!E#&fjxVoJV~A`Pm!m})8smNx?C^MkQ?MiIUvuJo1EXvv*c!EzMn0($gOgl z^BcKc?r=Wf+~?eiwZV5dx5=II9Jx#GmV4x0d9K_i-yzSF=gSM^h4LbKv3#d|m%Kz? zDle0l%PZuS@+!GsUM;VY@0Rb8*UIbU_3{S!UU{RuNxo0sEZ;9bAa9W$l()*;|B#2}pge-j(1nspDOaVaR25O9RGLax z8EUi|qcT;N%2qilSLLaERiFx0R28XWRiaAOSXHLVRfUSFN>!z*RgJ1ub*f%9s7BSK znpKNxRc)$Wbtoh`sBSe*^{DZxS4~h8)g(1pO;JHY&A#CRrAz* zwLmRYi_~JZ#CgN{gY%|3K`m9w)N-{#tyHVjYPCkKRVS*G)XC};b*ef|ty8C~_38|@ zL2Xn6>P)psouxLbv(*;0Rc%w-)ef~&ouhU+e^k5G9_LTapVeMLPWqdZ&7qxoAQE7XIU^*b)&jTy-(e&-mgBO zZc!gpx2oIJ?dn77!|D!or}~JxOWmzLs_s!AQ}?R-)W_BR>H+njdPsdjeNuf&eOi4+ zJ*+;fKBpd0pI48nFQ^0RG4;55LVZzvNqt#;MSWF$O?_Q`Lp`a!sh(0#t8b}qtM91q zs%O-*>N)j2^?mgN^}PC_`jL7;{aF1({ZzfEUQ$0(KUcp{zf>=)U#VBruhsvk->BcJ zgX(|P@6_+rtLl*YKlPe=UA>|Hpx#t}RDV)`R)0}{Rew`|S8u6*sKaVd9l=4x!j-Oa zT{p!|btCR5H_c6VGu+Yc7&p_+aq?XGdxx+l6P zxhK1)xTm_OIX^*M@~6&A&UMZ&oNHlB{ut~3Uvy7*e&()s&u}+5KX-oVZgdCSGu=(@ zS?*@{YC=?zQf9?)B~s?t9%E-J9I^xi`D-cR%3X;{Lzlt_Di3 zs=nX*=Iv~qs*&PTpGdnX$)|lXu5Qso`cC%l}WGBq-CLsib zBw~tDIVr6x#)uLTF(OUzqn5+rSWiz&m4k>DC`z=52oWjeC`E++?*IRPJ8uWGhPJ28 zJNeyr@4N5*Kkom2&imi}qTmt2lsZxw?wc`w$C>Kl{*=m4?-HAF%EuO6+8^p<=3MmRo`;e2`!YcvAbN$Gb>DrWIL78h6jiG%iX2n-nw^dNyL+% z40#h?Q>mFlqg%S|CQ`pJCK)pvrqUE9{Q`%{l!R%wFj}qHh*wAzDbdS{CbEIV`DHKL zCUGv*%Vs5>lQ1tK6Y+9VPENSYNqITpJ}2enq`aJzmy>dG#dxW^iaNB#ddYSwHd$cy z5T+VSTS`6E%1~pe(hn=y+*GPUfu)|&5qbq#NTOsR*J_JENE{&~@evXy$QOo08F9&< zCC(7fB}!B**Gq!g;>a{%j$@~kdIqaKqg(p0!N(~xN*f2UH8(ssT+5FVlN?Kw`l}@v zE;S)r6dt7{Cenp5mXTx4Bq`s|tVzuYiPFem8GC)vGKU2LIUY@wZO!1GO}xxdChsh+B@6A;$Pi{42Sv<5 z7BfCb)$I&!Y8>1&Zv38~YK?QWc~gx>Vsk{E#O8i5O#{=WqLPWj#NTQbeC#P zlq2uCW#}Qn`B@e`wpGUwHl)-9^GJz#v_#9{>`k_`u$bX;X?TDmtd2mcDM-bKn8Be4 zgMl>MpfxcRVNhPpH4l}xR)$Bam8}E4vEHHncqOV?>Xx8U5wg8Ftuio*{-Zj&rCb^v zX{@lGn=8Z=5pSprRH|a3d8v_t7*Jly$%~;Cg!6nDilOGi{K9*_HC|=Osw7#W8j&PE zOpls}YgNd?rBjLFNIsj0A0~=3`+KYS0RZETbT{q@tSmkf(OH`K^!%jo-%9*>$#g0{ z8dVSV*2zLa*etY88=at@Q8B|&Hp2$|;s6bgk})=n*7Q^mxr?HQLZ~;=jn$|E4+qEjAO(bbcP2qiy{u!v_H zvjvv9y(U&u`#8h#JXI)yYNa&7?0ZsJ(vKHNjCg@EH5JBR_{yl#!(N)^0%)9V zjVp!6mD1g<P}G2U3$kIM!1h(LXVm+sVR~LO8bdRBKbHm zQ*hVXzGZDhEol-DeT%T$N^GHT@#E{F+K?`=AY2vF)7DMU#yY9~dQQ@Fb?Iy#0naNS z7gtY^DoXz1#QfX_y?`eH2t_|ZoqhtcCp;H@1a$K7u-Z1Z);TAo2%w@&_V+ zP$z#N@&|SD2W(zJ(9UuN?R9eT5F7*rd0wb{F8T=S^ucRYFGz~LB*m(DIfrzSKUpU~ zuNU#0>=dEMo8+N3NYW6Ebde`nCr?u9Gg()kNvY4I$di=%O!AN(_}u&hU-RZetofDU-iqJT1j@(Md+i%?MpyMx%Tw&j=kXtEta1qFY`YdqF|?!x0PgoTqi3 z3G0g2%wCWcI$5E^YiztPba)!*1;vn#*bJ}Ly@1!~2<3TE^vP>@qzj)#3DaRYq25Dz z#CDAEZNzqr=-UXNST~Y9c`1)~A3#US;XME^XqEDLL`D5c{pO`UJ>erG=|!QR3F|k^ zC(p5C#&jj07uto?7q3rIKk_^+^}!=P(nTH~`C%7QE{~~Rzzb;TUFh@l5bsGj8KJ|| zTck@pc%dDH?}c_C{1t?sR(Z}tFzi5{^IR5o5WXjLcq_+|uzQwMltxQeK;s*Cyq)Wq1!28-G>WBc&U= zM`~|mkJR4s9x311JyObN_ed!p?~&3i?~zhQ+9Rbq+MV4vRM|o_=*H5pH$ubSG7Y}7 zG$@~?LHU>l-7*czNHpx7Z3o!WWcicaKO)ZZCk@M=~_+Y#biuYt#W1RmKF(wV-r zL&TYWz;@{evhOToIpS;wfo#oUEGNALvezqPH}VO69=j12`aI=ETO*#ab3AA9b0U9E zmiv5L$M3)2SA7Qk>q|FD-W>;*-cJz|Lz;9ZzJ_>q`r;R zw~_ibQr|}E+em#Isc$3oZKS@9)VGoPHr(%m9@n=Kd5y?xL=Q&l+lbzc)VGoPHau7J zjnuc1`ZiMEM(W#0eH*E7BlT^hzKztk;klS^q`nQ$#SrIumHr!X*015Y7vij6!*egh zxt!* zlM?zVu4j`H`n;k?xk8`U7KjUdUQ76f*AfVYKCc}R7y7(bKwRka+5mB(9|-+G=<{43 z&$->0Ke!HV|X6o8|n9q^m|77JtO^|k$%rezh|W1Gt%!F>FS=T8Vl{

nE%K*D|7npwE%Nhx1n-IddFhY1$e$MZ(?Vay zOT*iZzTx=;LaBclM-9)jkS_FP95phI8W~4TtLVQ~=(mdgTZMkB&~Fv`GVU4~ca4m@ zM#fzu&q-zN0ignpaQm;Tg9e`=&3HPWvd>EBF7 z^e_FZk^a?4ziOnPH9U{Q&lN(S=W~b)eV)%DUTmgq6JEa>$bLt zw^<=C5&cJAt}#t^=kY4a_hfeAapOUo;9j>D@~Gpjl7vaRQ5@9??F&u*Dai__)vDOHPe%Hje{;sb56OukeMO z#y4?S0lV<^n|22A^_#}GZ&l!kgA=UrecLV|zH9^D=-de0o(v%=Oe(6I{yUx z=U5uvKwa)06~nhHpRv9;V66!s4nL`4`Qoaz>Qvi_srlv7$Pm87buHtNgL7$Yoz4gU zoPsoZ;y_mCeQQ?DSF=0Uyl=jmFX=G}Q3mD1>95wwMlB@t2`?h-AiR{Y8w4#?4b7m0 z|G~Wrxmxhe@B%4gK8LZx95!@uIHgVLDF>z;1jPitQN|g?&g;MLzut=P>|Vq-b+6(3 zxp*vrumxYn&9fKo=izI(#rX0q%_;cS%|6NDo4OUT)%b>u^5K31zFXt_wRjfh=9GD$ zaB|m+;N)bk*YMpRF7P|aMUu2Yn)*wmv;1;7B&YByQk*z;4rydDX~pX0WUsKqrO;t` zg=r@jCZJh+Sagj71YYn^7NUu{lTlz;)R5CT!o%P_l7VaTt(PUcpjnc z>omS{x450~T*B`VUO;#nq5V_oIIW8AbA-z+Hg+}kQCqbAq|&?a9rZ4JJH8KJhu@0t zz;DMl-$#&g^4D&>eX`d+(3$M|7`S-rbt^cV?79tnpX_xzB%JKJ6Y{_HIs%8&J@<&Wu7>7gw+T$BGv_48qi&H}psHQk?2IE9dUZH+%=pmh9T{3zAo zQTTf(2hab{{!a(3xBvfMJp}KCo$yn*3E_TtAdKPK4}XLG@H4nu-wWS?n{XdqWAGmM z7JLLAh8Mt-l=qlEhA;i?TYMeI7yaQqheJfemTu|X02;62tNw|3nC2nSwUqg?wC#11 zTs9SNIWBQvd3acO7$Y8@1s_pPLJF26dxi0dA>?OAxS9P@y#hMF#$U7VRMwN^Qk)&G)#W;-UG)L@P|Z+R!guO)c%aNi z&pxQ%4bP>r#(CuM99p7%`1WM<1@NxP>YTb3bJ7*?rRmVC;1P4Nz8L;6m+19sH@t^R z@Mr3Quh1><5!$3~)n&a!eS-XgJ_)a&s``}m2T}*&1++&Ug742q)gAEjIjD}ny6#Zl zgcr{j)r0y=`Y3#|?$cja--UL7BZ_Z=z%lUitL$tK-swd!k^O$-XJ~xl6pIMI^ z^&C8Iex#m<&&|{71^pBKEc|Ys!`%60{VRCY{05fyAMirLrGBe_tAD3ncQpKG-gIJ4 zBc_*Aol|rJJY-JS)1BGQ9Nl6)V)SX&BSxPAkC+Ae9o8R4&w&?AQqOf#&IS5x__(ap zi{Z!ee%)&QRP;sgP3hLl;FB_-v+zlQ2Nt|fuGa1FKN;3}c%Y2xg7rbsMR=e*b+0I&WY)-v?igsd^B88>j0{@X}a-vjQ;((`9&P zEJ4k{2;6P3s&ipg_G}V+Vt@l%nt|E&TwE^Z%M0*iA z>hgPP8~mppS2v)}o>w>Naf+X`n=Vk zy3^`TeZgu<-DNeUj#y2>9|?Ma_YpLrj#`bV`>aOP*R6MoJqZIVpDuR`78cvxJ!(3w zg3g3i?j?ORLPG2JHx2%KI2^9d`*1Fu-NG|Za+rrPt%xm55}Me$ye=R_@h0A zzW?X?1$dH$eq^s%pD}ohIq(jfhCaOo-d<;upVz{14=*46&-w7&>To*A>*^|}M7~zN zPCqoh8J5gmRMv;;TC6ZX1l?QzsL(fROnjnl7muhr;R|&HK2J}=MqhGXh33zM4KB8} zN0v7PoJ}X*%z`iHd#r~iycw5TUql{uAPaBT_ra(2a(I3A!pHJzI00X$_Q*R9B$*BynFH_6bI?*e zw3DUic~`(Ab}f8gyWqxKhL_|Hbv@>};VL;tt7QBsE8-b{r4nuLG-%`9@QbyFBU}hS zmX&bfUI*XV5?qF@6Yw=Sv;RXv)s6-;A?;k);X-)Q`ZV6yk>_Ig-Clw5VgQc5BXCW= z0e-5M${A?S%@{e(fc>5gFWmQFEzByN01RcQ@fa!jBQ&N_ZRL?eIqF zbMGWPLU@$$8-x!KetUS>OS%scK0^3I!lwv-O86YmcV8rYh43|t4T>;Mm;k04S_o$o z;i$s|nWvO~VGlF2X*-GGUc)2Qb}mJ>g!$n+b0r{42tP zz}AM(5#B|358-`;-z0nx*w*k5gx?{2l<;xF9}_+U%rrbt_!8l-317DucL=8pk9H5o zrxTt^IEQdP;X=amhDWy!$9=*!!ixy=gdKzz+n?&=ml9q<*iASsE_<39l#IL0Bc+3T(mb*5XD&?8oB% zGQv*6i-EIr2VtJ@BEmL8pU?xIsuvTULpYCcHen0lOyFs{iO{vE-XMIH@MXf6fTzO` z-{LcbKPG&f@KM4?fM=+O2)|AE0O2wcWB za1b&a!TApN<9vtjU{&-KbaNc5px1F$MFR7!bHJZ1&i+kI8(MuJRL3NIRzmE)Q@Yi! zef}KHF*NBLac3>3JAD%Fknm;+?Ow9YcaMZeB(!?A>32!^q=Y|}@R)?QE^WT2Bn#r$G7PQW;zZj0Hofp@EA zu`UX;v2F@;v5geA#|9|O$2L(|h}pBscB^8nLg8|Hf3Lj1SMmFM6~Dh%@%wufzrR=U z`+Md6y_lid84>OD$4XRUHr7jFJH>5@Hja`Y*>tp+d6+jXQR!F@<;unSBzKv@BBfjU zHr>*<>7WM<;r&=YZVmw==~n`d&h>! zTs-+b-tTzg;K}dttXEF#IXua})xRE39zMYnjg!+8Xl@U@`3lgUc<00(!no9+eh%Az z9=i$0v77KBR(>zBcRtz_JnGeN)T`KCcn!M?udCnT%!d207IR`p@m@mD*JtYm`dui` zwhkSuLVFs&z1Vv~$M$4S><~tiS$2;>X8%RK9I0bSEo0_8gjp%h7lE}6BlQ60t0)_@ z^o!o|bnNZ$8ge(0uHFtAuEdEGC7d1wHOOoXNKb zDP8XMl;;M@^FiRCdm|976_(9q?L@rG-45(^cL4j`YbXbn{IU7?f1d3b@P97&;FN`t z6g-Q~;dIGkQ_vsArbphIjrhj#w;Z(N4)8kb1iJpk$y$b<&$jTtsO>JSXXc_`dTX!b zpcbZKEq5Vogw82K%UTDW*wZC0MQG2MSdS1VO~fukxEd|)a)kE0i46$tsS_VSXwROw z0--&D!k&+4&!M;qi|N-f>SNW869f+69Dx&h|3j-TR_)he#r?;)*DijuRqw!=CRaIK zPLE^P*MnGJZ^1bw+i*I`E}TkoBYw5L2`7=K=Gv*2^CV8Q!WScu0Z^~Sg(R2ax6vML0%m4fb>UjdW%-ySI?>#*7}#LGwc~9 zutJR-&EZK%_Dya7Dze6jDwbvoE`>GvGNjmeH`a;@>)Oq@*P!L1CR#wrruSjBYL8^w zgr}b%df&$=`~&Pw{tNbwp1|JGzhX@O5l&@z8e{HHuvhtS7@>cro(-wjPByY<8tHA| z^X+O3GLhC|ZV%3Uuv)i&AiBlwHsD^P^;y}P>3)>%XFw9WCp3sXq0MeZHPOzH{pB8W zM?6cw&hWbxP87OF&B30_{pw8YyF7@Ks2ur?1kfK&pD4@TlP$jk+_juQcaNg3{(rOweLzEYR&6azlZDy3 zZ@^B)OmIH{EwUsroSiYMT+*1`O|(6b{kI7JosH$8ez|WN=6fIUrd9a1;Jo?m_|Bkq Jhje=PzW|`Rr{(|v literal 0 HcmV?d00001 diff --git a/crates/zeddy/assets/fonts/ibm-plex-mono/LICENSE.txt b/crates/zeddy/assets/fonts/ibm-plex-mono/LICENSE.txt new file mode 100644 index 00000000..670c6c04 --- /dev/null +++ b/crates/zeddy/assets/fonts/ibm-plex-mono/LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/zeddy/build.rs b/crates/zeddy/build.rs index c7c41357..78cd4277 100644 --- a/crates/zeddy/build.rs +++ b/crates/zeddy/build.rs @@ -28,6 +28,27 @@ fn main() { let beside = out_dir_binary_dir().join("herdr"); if let Err(err) = std::fs::copy(&vendored, &beside) { println!("cargo::error=cannot place herdr at {}: {err}", beside.display()); + return; + } + + // A release archive's linker signature is not a valid signature after it + // has been copied into a development artifact directory. macOS otherwise + // kills the sidecar before `main` and Chartr sees only a missing socket. + // The final application bundle is signed as a whole by packaging; this + // ad-hoc signature makes the ordinary Cargo artifact executable meanwhile. + if target.contains("apple-darwin") { + let signed = std::process::Command::new("codesign") + .args(["--force", "--sign", "-"]) + .arg(&beside) + .status(); + match signed { + Ok(status) if status.success() => {} + Ok(status) => println!( + "cargo::error=codesign exited with {status} while signing {}", + beside.display() + ), + Err(error) => println!("cargo::error=cannot ad-hoc sign {}: {error}", beside.display()), + } } } diff --git a/crates/zeddy/src/actions.rs b/crates/zeddy/src/actions.rs new file mode 100644 index 00000000..f6695823 --- /dev/null +++ b/crates/zeddy/src/actions.rs @@ -0,0 +1,68 @@ +//! Semantic actions and Zed-compatible default key bindings. +//! +//! Keeping actions separate from handlers gives Chartr one command surface +//! for keymaps, buttons, menus, and the command palette. + +use gpui::{App, KeyBinding}; + +use crate::keymap::{KeymapAction, KeymapStore}; + +pub mod pane { + gpui::actions!( + pane, + [ + CloseActiveItem, + CloseAllItems, + JoinIntoNext, + SplitAndMoveLeft, + SplitAndMoveRight, + SplitAndMoveUp, + SplitAndMoveDown, + MoveLeft, + MoveRight, + MoveUp, + MoveDown + ] + ); +} + +pub mod workspace { + gpui::actions!( + workspace, + [ + NewTerminal, + ActivatePaneLeft, + ActivatePaneRight, + ActivatePaneUp, + ActivatePaneDown, + ToggleZoom + ] + ); +} + +pub mod command_palette { + gpui::actions!(command_palette, [Toggle]); +} + +pub mod settings { + gpui::actions!(settings, [Open]); +} + +pub fn init(keymap: &KeymapStore, cx: &mut App) { + let context = Some("Chartr"); + cx.bind_keys([ + KeyBinding::new(keymap.key(KeymapAction::CloseItem), pane::CloseActiveItem, context), + KeyBinding::new(keymap.key(KeymapAction::NewTerminal), workspace::NewTerminal, context), + KeyBinding::new(keymap.key(KeymapAction::FocusLeft), workspace::ActivatePaneLeft, context), + KeyBinding::new( + keymap.key(KeymapAction::FocusRight), + workspace::ActivatePaneRight, + context, + ), + KeyBinding::new(keymap.key(KeymapAction::FocusUp), workspace::ActivatePaneUp, context), + KeyBinding::new(keymap.key(KeymapAction::FocusDown), workspace::ActivatePaneDown, context), + KeyBinding::new(keymap.key(KeymapAction::ToggleZoom), workspace::ToggleZoom, context), + KeyBinding::new(keymap.key(KeymapAction::CommandPalette), command_palette::Toggle, context), + KeyBinding::new(keymap.key(KeymapAction::OpenSettings), settings::Open, context), + ]); +} diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 047d7589..c02d602b 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -1,364 +1,3200 @@ -//! The root view: the sessions, the mode, the plugins, and nothing else. +//! The window-level workspace. //! -//! Everything that can live below this file does. What is left here is only -//! what genuinely needs to see more than one of them at once — which pane the -//! workspace is showing, and what a chrome action means. +//! This follows Zed's `MultiWorkspace` ownership boundary: the window owns an +//! ordered collection of independently stateful space entities, keeps one +//! active, observes each child, and renders only the active one. A space owns +//! its sessions and selection; switching spaces therefore never moves or +//! recreates a session. -use std::{path::PathBuf, rc::Rc, time::Duration}; +use std::{ + collections::HashMap, + path::PathBuf, + rc::Rc, + time::{Duration, Instant}, +}; -use futures::{StreamExt as _, channel::mpsc}; -use gpui::{FocusHandle, Focusable, Task}; -use ui::prelude::*; +use gpui::{ + Anchor, AnyView, DragMoveEvent, Entity, EntityId, FocusHandle, Focusable, PathPromptOptions, + Role, +}; +use ui::{ + Banner, ContextMenu, DropdownMenu, DropdownStyle, IconPosition, ListItem, ListItemSpacing, + PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, prelude::*, +}; use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; -use zeddy_plugin::PaneKey; -use zeddy_plugin_host::{Catalog, PaneSource, Paths}; -use zeddy_vt::Size; +use zeddy_plugin::{InstanceContext, manifest::Multiplicity}; +use zeddy_plugin_host::{Catalog, FileBroker, PaneSource, Paths, SettingsSource}; use crate::{ - chrome::{self, Action, Entry}, + actions, + chrome::{self, Action, DraggedItem, Entry, SpaceEntries}, fonts::Fonts, + item::PluginItem, + keymap::{KeymapAction, KeymapStore}, keys, mode::Mode, palette, - session::Session, - terminal::{Appearance, Fit, TerminalElement}, + persistence::{ + SidebarScope, Snapshot, SpaceKind as PersistedSpaceKind, StateStore, WindowState, + }, + settings::{ + AppearanceContent, CHARTR_DARK, CHARTR_LIGHT, GeneralContent, PluginSettingsContent, + ResolvedSettings, SettingsPage, SettingsStore, TerminalContent, ThemeMode, + }, + space::{Kind as SpaceKind, Space, name_for}, + spaces::{self, Registry}, + terminal::{Appearance, TerminalElement}, + workspace::{Axis as PaneAxisDirection, Member, PaneId as LayoutPaneId, SplitDirection}, }; -/// How long to wait for the private backend before saying it did not come up. const BACKEND_TIMEOUT: Duration = Duration::from_secs(10); +const BACKEND_SUPERVISION: Duration = Duration::from_secs(2); +const BACKEND_STEADY: Duration = Duration::from_secs(60); -/// What the workspace is showing. #[derive(Debug, Clone, PartialEq, Eq)] -enum Showing { - Session(usize), - Plugin(PaneKey), - /// Before the first session exists, or after the last one is closed. - Empty, +enum Backend { + Starting, + Ready, + Recovering(String), + Failed(String), +} + +#[derive(Clone)] +struct DraggedPaneDivider { + axis_path: Vec, + divider: usize, + axis: PaneAxisDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaletteCommand { + NewTerminal, + CloseItem, + CloseAllItems, + SplitLeft, + SplitRight, + SplitUp, + SplitDown, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + JoinPane, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ToggleZoom, + OpenSettings, } +impl PaletteCommand { + const ALL: [(Self, &'static str, &'static str); 18] = [ + (Self::NewTerminal, "Workspace: New Terminal", "Ctrl+~"), + (Self::CloseItem, "Pane: Close Active Item", "Cmd/Ctrl+W"), + (Self::CloseAllItems, "Pane: Close All Items", ""), + (Self::SplitLeft, "Pane: Split and Move Left", ""), + (Self::SplitRight, "Pane: Split and Move Right", ""), + (Self::SplitUp, "Pane: Split and Move Up", ""), + (Self::SplitDown, "Pane: Split and Move Down", ""), + (Self::MoveLeft, "Pane: Move Active Item Left", ""), + (Self::MoveRight, "Pane: Move Active Item Right", ""), + (Self::MoveUp, "Pane: Move Active Item Up", ""), + (Self::MoveDown, "Pane: Move Active Item Down", ""), + (Self::JoinPane, "Pane: Join Into Next Pane", ""), + (Self::FocusLeft, "Pane: Focus Left", "Cmd/Ctrl+K ←"), + (Self::FocusRight, "Pane: Focus Right", "Cmd/Ctrl+K →"), + (Self::FocusUp, "Pane: Focus Up", "Cmd/Ctrl+K ↑"), + (Self::FocusDown, "Pane: Focus Down", "Cmd/Ctrl+K ↓"), + (Self::ToggleZoom, "Pane: Toggle Zoom", "Shift+Esc"), + (Self::OpenSettings, "Chartr: Open Settings", "Cmd/Ctrl+,"), + ]; +} + +impl Render for DraggedPaneDivider { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +/// The root view, analogous to Zed's `MultiWorkspace`. pub struct Zeddy { - client: Client, - workspace: Option, - sessions: Vec, - showing: Showing, + client: Option, + backend: Backend, + backend_ready_since: Option, + backend_restart_spent: bool, + supervision_started: bool, + registry: Option, + spaces: Vec>, + active: Option>, mode: Mode, catalog: Catalog, - fit: Fit, + plugins_restored: bool, + plugin_settings: Option<(String, AnyView)>, + settings: SettingsStore, + keymap: KeymapStore, + settings_open: bool, + settings_page: SettingsPage, + recording_keymap: Option, + keymap_restart_required: bool, + command_palette_open: bool, + command_palette_query: String, + command_palette_selected: usize, + rename_space: Option, + rename_query: String, + sidebar_scope: SidebarScope, + sidebar_width: f32, + window_bounds: Option, + state: Option, + last_persisted: Option, focus: FocusHandle, - /// The last thing that went wrong, shown in place of the workspace. One - /// slot, not a log: what the user needs is the reason the thing they just - /// tried did not happen. problem: Option, - _wakeups: Task<()>, - wakeup_tx: mpsc::UnboundedSender<()>, } impl Zeddy { - pub fn new(cwd: PathBuf, cx: &mut Context) -> Self { - let namespace = Namespace::private(); - let client = match Sidecar::beside_current_exe() { - Ok(sidecar) => Client::new(sidecar, namespace), - Err(err) => { - // Without a backend there is nothing to show, but the window - // still opens: a window that says why is more useful than one - // that never appears. - return Self::broken(err.to_string(), cx); + pub fn new( + cwd: PathBuf, + settings: SettingsStore, + keymap: KeymapStore, + cx: &mut Context, + ) -> Self { + let (state, saved, state_problem) = + match crate::persistence::state_file().and_then(StateStore::open) { + Ok(store) => match store.load() { + Ok(saved) => (Some(store), saved, None), + Err(error) => (Some(store), Snapshot::default(), Some(error.to_string())), + }, + Err(error) => (None, Snapshot::default(), Some(error.to_string())), + }; + let saved_json = serde_json::to_string(&saved).ok(); + let client = + Sidecar::beside_current_exe().map(|sidecar| Client::new(sidecar, Namespace::private())); + let client = match client { + Ok(client) => client, + Err(error) => { + return Self { + client: None, + backend: Backend::Failed(error.to_string()), + backend_ready_since: None, + backend_restart_spent: false, + supervision_started: false, + registry: None, + spaces: Vec::new(), + active: None, + mode: Mode::default(), + catalog: Catalog::default(), + plugins_restored: true, + plugin_settings: None, + settings, + keymap, + settings_open: false, + settings_page: SettingsPage::default(), + recording_keymap: None, + keymap_restart_required: false, + command_palette_open: false, + command_palette_query: String::new(), + command_palette_selected: 0, + rename_space: None, + rename_query: String::new(), + sidebar_scope: saved.window.sidebar_scope, + sidebar_width: saved.window.sidebar_width, + window_bounds: saved.window.bounds, + state, + last_persisted: saved_json, + focus: cx.focus_handle(), + problem: Some(state_problem.unwrap_or_else(|| error.to_string())), + }; } }; - let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); + let (registry, registry_problem) = load_registry(&cwd); + let mut descriptors = Vec::new(); + let home = settings + .resolved() + .ad_hoc_directory + .clone() + .or_else(std::env::home_dir) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| cwd.clone()); + descriptors.push(("Ad-hoc sessions".to_owned(), home.clone(), SpaceKind::AdHoc)); + if let Some(registry) = registry.as_ref() { + descriptors.extend( + registry + .spaces() + .iter() + // The synthetic ad-hoc space already owns the home + // workspace. herdr has one workspace per directory, so a + // second row for the same path could not own independent + // sessions and would be a false distinction. + .filter(|space| !spaces::same_path(space.path(), &home)) + .map(|space| { + (space.name().to_owned(), space.path().to_path_buf(), SpaceKind::Registered) + }), + ); + } + for saved_space in &saved.spaces { + if saved_space.kind != PersistedSpaceKind::Folder { + continue; + } + let Some(path) = saved_space.path.clone() else { + continue; + }; + if descriptors.iter().all(|(_, existing, _)| !spaces::same_path(existing, &path)) { + descriptors.push((saved_space.name.clone(), path, SpaceKind::Registered)); + } + } + if descriptors.is_empty() { + descriptors.push(( + name_for(SpaceKind::Registered, &cwd), + cwd.clone(), + SpaceKind::Registered, + )); + } + + let spaces: Vec<_> = descriptors + .into_iter() + .map(|(name, path, kind)| { + let space = cx.new(|cx| Space::new(name, path, kind, client.clone(), cx)); + cx.observe(&space, |_, _, cx| cx.notify()).detach(); + space + }) + .collect(); + for space in &spaces { + let key = space.read(cx).persisted().key; + if let Some(saved_space) = saved.spaces.iter().find(|saved| saved.key == key) { + space.update(cx, |space, _| space.restore_saved(saved_space)); + } + } + let active = saved + .window + .active_space + .as_ref() + .and_then(|key| { + spaces.iter().find(|space| space.read(cx).persisted().key == *key).cloned() + }) + .or_else(|| { + spaces + .iter() + .find(|space| { + let space = space.read(cx); + space.kind() == SpaceKind::Registered + && spaces::same_path(space.path(), &cwd) + }) + .cloned() + }) + .or_else(|| spaces.first().cloned()); + + let catalog = zeddy_plugin_host::load_all_where( + &plugin_paths(), + |id| settings.resolved().plugin(id).enabled, + cx, + ); let mut this = Self { - client, - workspace: None, - sessions: Vec::new(), - showing: Showing::Empty, - mode: Mode::default(), - catalog: Catalog::default(), - fit: Fit::default(), + client: Some(client), + backend: Backend::Starting, + backend_ready_since: None, + backend_restart_spent: false, + supervision_started: false, + registry, + spaces, + active, + mode: saved.window.chrome, + catalog, + plugins_restored: false, + plugin_settings: None, + settings, + keymap, + settings_open: false, + settings_page: SettingsPage::default(), + recording_keymap: None, + keymap_restart_required: false, + command_palette_open: false, + command_palette_query: String::new(), + command_palette_selected: 0, + rename_space: None, + rename_query: String::new(), + sidebar_scope: saved.window.sidebar_scope, + sidebar_width: saved.window.sidebar_width, + window_bounds: saved.window.bounds, + state, + last_persisted: saved_json, focus: cx.focus_handle(), - problem: None, - _wakeups: Self::watch(wakeup_rx, cx), - wakeup_tx, + problem: state_problem.or(registry_problem), }; - - this.catalog = zeddy_plugin_host::load_all(&plugin_paths(), cx); - this.connect(cwd, cx); + this.connect(cx); this } - /// A window with no backend behind it. Everything is empty and the problem - /// is on screen. - fn broken(problem: String, cx: &mut Context) -> Self { - let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); - Self { - client: Client::new( - Sidecar::at(PathBuf::from("/nonexistent")).unwrap_or_else(|_| unreachable!()), - Namespace::private(), + /// Apply the explicit exit policy while the window and its entities are + /// still reachable. The default does nothing; `Space::drop` then sends a + /// clean release to every attachment so Herdr can be adopted next launch. + pub fn apply_exit_policy(&mut self, cx: &mut Context) { + if !self.settings.resolved().terminate_sessions_on_exit { + return; + } + let Some(client) = self.client.clone() else { + return; + }; + for space in &self.spaces { + let ids = space.read(cx).all_item_ids(); + let targets = space.read(cx).close_targets(&ids); + let closed: Vec<_> = targets + .into_iter() + .filter_map(|(item, backend)| match backend { + None => Some(item), + Some(backend) if client.close_session(&backend).is_ok() => Some(item), + Some(_) => None, + }) + .collect(); + space.update(cx, |space, _| space.finish_bulk_close(&closed)); + } + } + + /// Bring the private backend up and take one snapshot of every running + /// session. Like Zed's project I/O, the blocking transport stays on the + /// background executor and only owned answers return to GPUI. + fn connect(&mut self, cx: &mut Context) { + let Some(client) = self.client.clone() else { + return; + }; + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let result = executor + .spawn(async move { + client.connect(BACKEND_TIMEOUT)?; + client.sessions(None) + }) + .await; + let _ = this.update(cx, |this, cx| match result { + Ok(infos) => { + this.backend_became_ready(); + this.distribute(infos, cx); + this.start_supervision(cx); + cx.notify(); + } + Err(error) => { + let problem = error.to_string(); + this.backend = Backend::Failed(problem.clone()); + this.backend_ready_since = None; + cx.notify(); + } + }); + }) + .detach(); + } + + fn backend_became_ready(&mut self) { + self.backend = Backend::Ready; + self.backend_ready_since = Some(Instant::now()); + } + + /// Supervise the private daemon on the same discipline as Chartr-rs: the + /// socket is checked every two seconds, the first failure in an episode is + /// given one clean replacement, and a replacement that cannot hold for a + /// minute is exposed as a crash loop rather than restarted again. + fn start_supervision(&mut self, cx: &mut Context) { + if self.supervision_started { + return; + } + self.supervision_started = true; + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + loop { + executor.timer(BACKEND_SUPERVISION).await; + let check = this.update(cx, |this, _| { + matches!(this.backend, Backend::Ready).then(|| this.client.clone()).flatten() + }); + let Ok(Some(client)) = check else { + continue; + }; + let probe = client.clone(); + if executor.spawn(async move { probe.answers() }).await { + let _ = this.update(cx, |this, _| { + if this.backend_restart_spent + && this + .backend_ready_since + .is_some_and(|since| since.elapsed() >= BACKEND_STEADY) + { + this.backend_restart_spent = false; + } + }); + continue; + } + let _ = this.update(cx, |this, cx| this.backend_died(client, cx)); + } + }) + .detach(); + } + + fn backend_died(&mut self, client: Client, cx: &mut Context) { + if !matches!(self.backend, Backend::Ready) { + return; + } + if self.backend_ready_since.is_some_and(|since| since.elapsed() >= BACKEND_STEADY) { + self.backend_restart_spent = false; + } + self.drop_dead_terminals(cx); + self.backend_ready_since = None; + + if self.backend_restart_spent { + let problem = "The terminal backend failed again before it held for 60 seconds. Chartr stopped automatic recovery to expose the crash loop.".to_owned(); + self.backend = Backend::Failed(problem); + let executor = cx.background_executor().clone(); + executor.spawn(async move { client.clear_saved_shape() }).detach(); + cx.notify(); + return; + } + + self.backend_restart_spent = true; + self.backend = Backend::Recovering( + "The terminal backend stopped answering. Starting one clean replacement…".to_owned(), + ); + cx.notify(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let result = executor + .spawn(async move { + client.restart()?; + client.reconnect(BACKEND_TIMEOUT)?; + client.sessions(None) + }) + .await; + let _ = this.update(cx, |this, cx| match result { + Ok(infos) => { + this.backend_became_ready(); + this.distribute(infos, cx); + cx.notify(); + } + Err(error) => { + this.backend = Backend::Failed(format!( + "Chartr could not recover the terminal backend: {error}" + )); + this.backend_ready_since = None; + cx.notify(); + } + }); + }) + .detach(); + } + + fn drop_dead_terminals(&mut self, cx: &mut Context) { + for space in &self.spaces { + space.update(cx, |space, _| space.drop_dead_sessions()); + } + } + + fn retry_backend(&mut self, clean_restart: bool, cx: &mut Context) { + let Some(client) = self.client.clone() else { + return; + }; + if clean_restart { + self.drop_dead_terminals(cx); + } + self.backend = if clean_restart { + Backend::Recovering("Restarting the terminal backend…".to_owned()) + } else { + Backend::Starting + }; + self.backend_ready_since = None; + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let result = executor + .spawn(async move { + if clean_restart { + client.restart()?; + client.reconnect(BACKEND_TIMEOUT)?; + } else { + client.connect(BACKEND_TIMEOUT)?; + } + client.sessions(None) + }) + .await; + let _ = this.update(cx, |this, cx| match result { + Ok(infos) => { + this.backend_restart_spent = false; + this.backend_became_ready(); + this.distribute(infos, cx); + this.start_supervision(cx); + cx.notify(); + } + Err(error) => { + this.backend = Backend::Failed(error.to_string()); + this.backend_ready_since = None; + cx.notify(); + } + }); + }) + .detach(); + } + + fn request_backend_restart(&mut self, window: &mut Window, cx: &mut Context) { + let terminal_count: usize = self + .spaces + .iter() + .map(|space| { + let ids = space.read(cx).all_item_ids(); + space + .read(cx) + .close_targets(&ids) + .iter() + .filter(|(_, backend)| backend.is_some()) + .count() + }) + .sum(); + if terminal_count <= 1 { + self.retry_backend(true, cx); + return; + } + let prompt = window.prompt( + gpui::PromptLevel::Critical, + "Restart the terminal backend?", + Some(&format!( + "Restarting is destructive: all {terminal_count} underlying sessions will be terminated." + )), + &["Restart and Terminate", "Cancel"], + cx, + ); + cx.spawn(async move |this, cx| { + if prompt.await == Ok(0) { + let _ = this.update(cx, |this, cx| this.retry_backend(true, cx)); + } + }) + .detach(); + } + + /// Partition a single backend snapshot by workspace path, then let each + /// space attach its own sessions. + fn distribute(&mut self, infos: Vec, cx: &mut Context) { + let paths_by_workspace: HashMap = infos + .iter() + .filter_map(|info| info.cwd.clone().map(|cwd| (info.workspace.clone(), cwd))) + .collect(); + let mut by_space: HashMap> = HashMap::new(); + + for info in infos { + let path = + info.cwd.clone().or_else(|| paths_by_workspace.get(&info.workspace).cloned()); + let Some(path) = path else { + continue; + }; + let target = self.spaces.iter().find(|space| { + let space = space.read(cx); + spaces::same_path(space.path(), &path) + }); + if let Some(target) = target { + by_space.entry(target.entity_id()).or_default().push(info); + } + } + + for space in &self.spaces { + if let Some(infos) = by_space.remove(&space.entity_id()) { + space.update(cx, |space, cx| space.adopt(infos, cx)); + } + } + } + + fn active_space(&self) -> Option<&Entity> { + self.active.as_ref() + } + + fn snapshot(&self, cx: &App) -> Snapshot { + Snapshot { + window: WindowState { + chrome: self.mode, + sidebar_scope: self.sidebar_scope, + sidebar_width: self.sidebar_width, + active_space: self.active.as_ref().map(|space| space.read(cx).persisted().key), + bounds: self.window_bounds, + ..WindowState::default() + }, + spaces: self.spaces.iter().map(|space| space.read(cx).persisted()).collect(), + } + } + + fn persist_if_changed(&mut self, cx: &mut Context) { + let snapshot = self.snapshot(cx); + let Ok(encoded) = serde_json::to_string(&snapshot) else { + return; + }; + if self.last_persisted.as_deref() == Some(encoded.as_str()) { + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + match state.save(&snapshot) { + Ok(()) => self.last_persisted = Some(encoded), + Err(error) => self.problem = Some(error.to_string()), + } + } + + fn activate(&mut self, space: Entity, window: &mut Window, cx: &mut Context) { + if self.active.as_ref() == Some(&space) { + window.focus(&self.focus, cx); + return; + } + self.active = Some(space.clone()); + space.update(cx, |space, _| space.fit_items()); + window.focus(&self.focus, cx); + cx.notify(); + } + + fn pick_a_folder(&mut self, cx: &mut Context) { + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Add".into()), + }); + cx.spawn(async move |this, cx| { + let outcome = chosen.await; + let _ = this.update(cx, |this, cx| match outcome { + Ok(Ok(Some(paths))) => { + for path in paths { + this.register(path, cx); + } + } + Ok(Ok(None)) => {} + Ok(Err(error)) => { + this.problem = Some(format!("choosing a folder: {error}")); + cx.notify(); + } + Err(_) => { + this.problem = Some("the folder picker closed unexpectedly".into()); + cx.notify(); + } + }); + }) + .detach(); + } + + fn register(&mut self, path: PathBuf, cx: &mut Context) { + let Some(registry) = self.registry.as_mut() else { + self.problem = Some("the space registry is unavailable".into()); + cx.notify(); + return; + }; + let path = match registry.register(&path) { + Ok(path) => path, + Err(error) => { + self.problem = Some(error.to_string()); + cx.notify(); + return; + } + }; + if let Some(existing) = self + .spaces + .iter() + .find(|space| spaces::same_path(space.read(cx).path(), &path)) + .cloned() + { + self.active = Some(existing); + self.problem = None; + cx.notify(); + return; + } + + let Some(client) = self.client.clone() else { + return; + }; + let name = name_for(SpaceKind::Registered, &path); + let space = cx.new(|cx| Space::new(name, path, SpaceKind::Registered, client, cx)); + cx.observe(&space, |_, _, cx| cx.notify()).detach(); + self.spaces.push(space.clone()); + self.active = Some(space); + self.problem = None; + self.refresh(cx); + cx.notify(); + } + + fn refresh(&mut self, cx: &mut Context) { + if !matches!(self.backend, Backend::Ready) { + return; + } + let Some(client) = self.client.clone() else { + return; + }; + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let request = client.clone(); + let (result, answers) = executor + .spawn(async move { + let result = request.sessions(None); + let answers = request.answers(); + (result, answers) + }) + .await; + let _ = this.update(cx, |this, cx| match result { + Ok(infos) => this.distribute(infos, cx), + Err(_) if !answers => this.backend_died(client, cx), + Err(error) => this.problem = Some(error.to_string()), + }); + }) + .detach(); + } + + fn entries(&self, cx: &App) -> Vec { + self.active_space() + .map(|space| space.read(cx).entries(space.entity_id())) + .unwrap_or_default() + } + + fn sidebar_spaces(&self, cx: &App) -> Vec { + let spaces: Vec<_> = match self.sidebar_scope { + SidebarScope::AllSpaces => self.spaces.iter().collect(), + SidebarScope::ActiveSpace => self.active.iter().collect(), + }; + spaces + .into_iter() + .map(|space| { + let read = space.read(cx); + SpaceEntries { + id: space.entity_id(), + name: read.name().to_owned(), + removable: read.kind() == SpaceKind::Registered, + available: read.available(), + panes: read.pane_entries(space.entity_id()), + entries: read.entries(space.entity_id()), + } + }) + .collect() + } + + fn act(&mut self, action: Action, window: &mut Window, cx: &mut Context) { + match action { + Action::ToggleMode => self.mode = self.mode.toggled(), + Action::ToggleSidebarScope => { + self.sidebar_scope = match self.sidebar_scope { + SidebarScope::AllSpaces => SidebarScope::ActiveSpace, + SidebarScope::ActiveSpace => SidebarScope::AllSpaces, + } + } + Action::New => { + if matches!(self.backend, Backend::Ready) + && let Some(space) = self.active.clone() + { + space.update(cx, |space, cx| space.start_session(cx)); + } + } + Action::NewInSpace { space } => { + if matches!(self.backend, Backend::Ready) + && let Some(target) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + self.activate(target.clone(), window, cx); + target.update(cx, |space, cx| space.start_session(cx)); + } + } + Action::ClosePane { space, pane } => self.request_close_pane(space, pane, window, cx), + Action::CloseSpace { space } => self.request_close_space(space, window, cx), + Action::RenameSpace { space } => { + if let Some(target) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space) + { + self.rename_space = Some(space); + self.rename_query = target.read(cx).name().to_owned(); + window.focus(&self.focus, cx); + } + } + Action::LocateSpace { space } => self.locate_space(space, cx), + action @ Action::MoveToPane { space, item, target: target_pane, .. } => { + if let Some(target_space) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + let clone = cfg!(target_os = "macos") && window.modifiers().alt + || cfg!(not(target_os = "macos")) && window.modifiers().control; + if clone + && self.clone_plugin_drop( + target_space.clone(), + item, + target_pane, + None, + window, + cx, + ) + { + cx.notify(); + return; + } + target_space.update(cx, |space, cx| space.act(action, cx)); + } + } + action @ (Action::Select { .. } | Action::Close { .. }) => { + let target = match &action { + Action::Select { space, .. } | Action::Close { space, .. } => space + .and_then(|id| self.spaces.iter().find(|space| space.entity_id() == id)) + .cloned() + .or_else(|| self.active.clone()), + _ => None, + }; + if let Some(space) = target { + if matches!(action, Action::Select { .. }) { + self.activate(space.clone(), window, cx); + } + space.update(cx, |space, cx| space.act(action, cx)); + } + } + } + cx.notify(); + } + + fn close_active_item(&mut self, cx: &mut Context) { + if self.command_palette_open { + self.command_palette_open = false; + self.command_palette_query.clear(); + cx.notify(); + return; + } + if self.settings_open { + self.settings_open = false; + self.plugin_settings = None; + cx.notify(); + return; + } + let Some(space) = self.active.clone() else { + return; + }; + let active = space.read(cx).active(); + if let Some(active) = active { + space + .update(cx, |space, cx| space.act(Action::Close { space: None, item: active }, cx)); + } + } + + fn request_close_pane( + &mut self, + space_id: EntityId, + pane: LayoutPaneId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(space) = self.spaces.iter().find(|space| space.entity_id() == space_id).cloned() + else { + return; + }; + let ids = space.read(cx).pane_item_ids(pane); + self.request_bulk_close(space, ids, false, window, cx); + } + + fn request_close_active_pane(&mut self, window: &mut Window, cx: &mut Context) { + let Some(space) = self.active.clone() else { + return; + }; + let pane = space.read(cx).layout().active_pane(); + let ids = space.read(cx).pane_item_ids(pane); + self.request_bulk_close(space, ids, false, window, cx); + } + + fn request_close_space( + &mut self, + space_id: EntityId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(space) = self.spaces.iter().find(|space| space.entity_id() == space_id).cloned() + else { + return; + }; + if space.read(cx).kind() == SpaceKind::AdHoc { + return; + } + let ids = space.read(cx).all_item_ids(); + self.request_bulk_close(space, ids, true, window, cx); + } + + fn request_bulk_close( + &mut self, + space: Entity, + ids: Vec, + remove_space: bool, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_count = space + .read(cx) + .close_targets(&ids) + .iter() + .filter(|(_, backend)| backend.is_some()) + .count(); + if terminal_count <= 1 { + self.start_bulk_close(space, ids, remove_space, cx); + return; + } + + let noun = if remove_space { "space" } else { "pane" }; + let message = format!("Close this {noun} and terminate {terminal_count} sessions?"); + let detail = + "Closing is destructive: every underlying shell or agent process is terminated."; + let prompt = window.prompt( + gpui::PromptLevel::Critical, + &message, + Some(detail), + &["Close and Terminate", "Cancel"], + cx, + ); + cx.spawn(async move |this, cx| { + if prompt.await == Ok(0) { + let _ = + this.update(cx, |this, cx| this.start_bulk_close(space, ids, remove_space, cx)); + } + }) + .detach(); + } + + fn start_bulk_close( + &mut self, + space: Entity, + ids: Vec, + remove_space: bool, + cx: &mut Context, + ) { + let targets = space.read(cx).close_targets(&ids); + let client = self.client.clone(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let results = executor + .spawn(async move { + targets + .into_iter() + .map(|(item, backend)| { + let result = match (&client, backend) { + (_, None) => Ok(()), + (Some(client), Some(backend)) => client.close_session(&backend), + (None, Some(_)) => Err(zeddy_herdr::Error::Protocol( + "the terminal backend is unavailable".to_owned(), + )), + }; + (item, result) + }) + .collect::>() + }) + .await; + let _ = this.update(cx, |this, cx| { + let successful: Vec<_> = results + .iter() + .filter_map(|(item, result)| result.is_ok().then_some(*item)) + .collect(); + let failures: Vec<_> = results + .iter() + .filter_map(|(_, result)| result.as_ref().err().map(ToString::to_string)) + .collect(); + space.update(cx, |space, _| space.finish_bulk_close(&successful)); + if !failures.is_empty() { + this.problem = Some(failures.join("\n")); + } else if remove_space { + this.remove_space_after_close(&space, cx); + } + cx.notify(); + }); + }) + .detach(); + } + + fn remove_space_after_close(&mut self, space: &Entity, cx: &mut Context) { + let path = space.read(cx).path().clone(); + if let Some(registry) = self.registry.as_mut() + && let Err(error) = registry.remove(&path) + { + self.problem = Some(error.to_string()); + return; + } + let Some(index) = self.spaces.iter().position(|candidate| candidate == space) else { + return; + }; + let was_active = self.active.as_ref() == Some(space); + self.spaces.remove(index); + if was_active { + self.active = self.spaces.get(index.min(self.spaces.len().saturating_sub(1))).cloned(); + } + } + + fn commit_space_rename(&mut self, cx: &mut Context) { + let Some(id) = self.rename_space.take() else { + return; + }; + let name = self.rename_query.trim().to_owned(); + self.rename_query.clear(); + let Some(space) = self.spaces.iter().find(|space| space.entity_id() == id).cloned() else { + return; + }; + let path = space.read(cx).path().clone(); + let result = self + .registry + .as_mut() + .map(|registry| registry.rename(&path, name.clone())) + .unwrap_or(Ok(())); + match result { + Ok(()) => { + space.update(cx, |space, _| space.set_name(name)); + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn locate_space(&mut self, id: EntityId, cx: &mut Context) { + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Locate Space".into()), + }); + cx.spawn(async move |this, cx| { + let outcome = chosen.await; + let _ = this.update(cx, |this, cx| match outcome { + Ok(Ok(Some(paths))) if !paths.is_empty() => { + let Some(space) = + this.spaces.iter().find(|space| space.entity_id() == id).cloned() + else { + return; + }; + let old = space.read(cx).path().clone(); + let new = paths[0].clone(); + let result = this + .registry + .as_mut() + .map(|registry| registry.relocate(&old, &new)) + .unwrap_or(Ok(new)); + match result { + Ok(path) => { + space.update(cx, |space, _| space.set_path(path)); + this.problem = None; + this.refresh(cx); + } + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + } + Ok(Ok(_)) | Err(_) => {} + Ok(Err(error)) => { + this.problem = Some(error.to_string()); + cx.notify(); + } + }); + }) + .detach(); + } + + fn open_settings(&mut self, cx: &mut Context) { + self.command_palette_open = false; + self.settings_open = true; + self.settings_page = SettingsPage::default(); + self.plugin_settings = None; + cx.notify(); + } + + fn cycle_settings_page(&mut self, backwards: bool, cx: &mut Context) { + let current = + SettingsPage::ALL.iter().position(|page| *page == self.settings_page).unwrap_or(0); + let next = if backwards { + current.checked_sub(1).unwrap_or(SettingsPage::ALL.len() - 1) + } else { + (current + 1) % SettingsPage::ALL.len() + }; + self.settings_page = SettingsPage::ALL[next]; + self.plugin_settings = None; + cx.notify(); + } + + fn set_theme_preference( + &mut self, + mode: ThemeMode, + fixed_theme: Option<&str>, + cx: &mut Context, + ) { + let result = self.settings.update(|content| { + let appearance = content.appearance.get_or_insert_with(AppearanceContent::default); + appearance.theme_mode = Some(mode); + if let Some(theme) = fixed_theme { + appearance.fixed_theme = Some(theme.to_owned()); + } + }); + match result { + Ok(settings) => { + crate::settings::apply_theme(settings, cx); + theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn set_terminate_on_exit(&mut self, enabled: bool, cx: &mut Context) { + let result = self.settings.update(|content| { + content + .general + .get_or_insert_with(GeneralContent::default) + .terminate_sessions_on_exit = Some(enabled); + }); + match result { + Ok(_) => self.problem = None, + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn close_plugin_instances(&mut self, plugin: &str, cx: &mut Context) { + for space in &self.spaces { + let ids = space.read(cx).plugin_item_ids(plugin); + space.update(cx, |space, _| space.finish_bulk_close(&ids)); + } + } + + fn set_plugin_enabled(&mut self, plugin: String, enabled: bool, cx: &mut Context) { + if enabled { + match self.catalog.enable(&plugin_paths(), &plugin, cx) { + Ok(()) => {} + Err(error) => { + self.problem = Some(error.to_string()); + cx.notify(); + return; + } + } + } + let result = self.settings.update(|content| { + content + .plugins + .entry(plugin.clone()) + .or_insert_with(PluginSettingsContent::default) + .enabled = Some(enabled); + }); + match result { + Ok(_) => { + if !enabled { + self.close_plugin_instances(&plugin, cx); + if self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { + self.plugin_settings = None; + } + self.catalog.disable(&plugin); + } + self.problem = None; + } + Err(error) => { + if enabled { + self.catalog.disable(&plugin); + } + self.problem = Some(error.to_string()); + } + } + cx.notify(); + } + + fn set_plugin_unsafe(&mut self, plugin: String, enabled: bool, cx: &mut Context) { + let result = self.settings.update(|content| { + content + .plugins + .entry(plugin.clone()) + .or_insert_with(PluginSettingsContent::default) + .unsafe_filesystem = Some(enabled); + }); + match result { + Ok(_) => { + // Brokers are instance-owned. Destroying the view is the + // revocation boundary; reopening constructs one with the new grant. + self.close_plugin_instances(&plugin, cx); + if self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { + self.plugin_settings = None; + } + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn open_plugin_settings( + &mut self, + plugin: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(loaded) = self.catalog.get(&plugin) else { + return; + }; + let permissions = loaded.permissions().clone(); + let unsafe_filesystem = self.settings.resolved().plugin(&plugin).unsafe_filesystem; + let source = self.catalog.get_mut(&plugin).and_then(|loaded| loaded.settings(window, cx)); + let view = match source { + Some(SettingsSource::Native(view)) => view, + Some(SettingsSource::Web(entry)) => crate::web_plugin::view( + entry, + FileBroker::new( + None, + plugin_paths().data.join(&plugin), + permissions.project_files, + unsafe_filesystem, + ), + permissions, + None, + window, + cx, ), - workspace: None, - sessions: Vec::new(), - showing: Showing::Empty, - mode: Mode::default(), - catalog: Catalog::default(), - fit: Fit::default(), - focus: cx.focus_handle(), - problem: Some(problem), - _wakeups: Self::watch(wakeup_rx, cx), - wakeup_tx, + None => return, + }; + self.plugin_settings = Some((plugin, view)); + cx.notify(); + } + + fn set_ui_font(&mut self, family: String, cx: &mut Context) { + let result = self.settings.update(|content| { + content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_family = + Some(family); + }); + match result { + Ok(settings) => { + theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn adjust_ui_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = self.settings.resolved().ui_font_size; + let result = self.settings.update(|content| { + content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_size = + Some((current + delta).clamp(8., 32.)); + }); + match result { + Ok(settings) => { + theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn set_terminal_font(&mut self, family: String, cx: &mut Context) { + let result = self.settings.update(|content| { + content.terminal.get_or_insert_with(TerminalContent::default).font_family = + Some(family); + }); + match result { + Ok(_) => self.problem = None, + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn adjust_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = self.settings.resolved().terminal_font_size; + let result = self.settings.update(|content| { + content.terminal.get_or_insert_with(TerminalContent::default).font_size = + Some((current + delta).clamp(8., 72.)); + }); + match result { + Ok(_) => self.problem = None, + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn pick_ad_hoc_directory(&mut self, cx: &mut Context) { + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Use for Ad-hoc sessions".into()), + }); + cx.spawn(async move |this, cx| { + let outcome = chosen.await; + let _ = this.update(cx, |this, cx| match outcome { + Ok(Ok(Some(paths))) if !paths.is_empty() => { + let path = paths[0].clone(); + match this.settings.update(|content| { + content + .terminal + .get_or_insert_with(TerminalContent::default) + .ad_hoc_directory = Some(path.clone()); + }) { + Ok(_) => { + if let Some(space) = this + .spaces + .iter() + .find(|space| space.read(cx).kind() == SpaceKind::AdHoc) + { + space.update(cx, |space, _| space.set_path(path)); + } + this.problem = None; + } + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + } + Ok(Ok(_)) | Err(_) => {} + Ok(Err(error)) => { + this.problem = Some(error.to_string()); + cx.notify(); + } + }); + }) + .detach(); + } + + fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.split_and_move(direction)); + cx.notify(); + } + } + + fn move_active_to_pane(&mut self, direction: SplitDirection, cx: &mut Context) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.move_active_to_pane(direction)); + cx.notify(); + } + } + + fn activate_pane_in_direction( + &mut self, + direction: SplitDirection, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.activate_pane_in_direction(direction)); + window.focus(&self.focus, cx); + cx.notify(); + } + } + + fn join_active_into_next(&mut self, cx: &mut Context) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.join_active_into_next()); + cx.notify(); + } + } + + fn toggle_zoom(&mut self, cx: &mut Context) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.toggle_zoom()); + cx.notify(); + } + } + + fn toggle_command_palette(&mut self, window: &mut Window, cx: &mut Context) { + self.command_palette_open = !self.command_palette_open; + self.command_palette_query.clear(); + self.command_palette_selected = 0; + window.focus(&self.focus, cx); + cx.notify(); + } + + fn filtered_palette_commands(&self) -> Vec<(PaletteCommand, &'static str, &'static str)> { + let query = self.command_palette_query.trim().to_lowercase(); + PaletteCommand::ALL + .into_iter() + .filter(|(_, label, _)| query.is_empty() || label.to_lowercase().contains(&query)) + .collect() + } + + fn invoke_palette_command( + &mut self, + command: PaletteCommand, + window: &mut Window, + cx: &mut Context, + ) { + self.command_palette_open = false; + self.command_palette_query.clear(); + let action: Box = match command { + PaletteCommand::NewTerminal => Box::new(actions::workspace::NewTerminal), + PaletteCommand::CloseItem => Box::new(actions::pane::CloseActiveItem), + PaletteCommand::CloseAllItems => Box::new(actions::pane::CloseAllItems), + PaletteCommand::SplitLeft => Box::new(actions::pane::SplitAndMoveLeft), + PaletteCommand::SplitRight => Box::new(actions::pane::SplitAndMoveRight), + PaletteCommand::SplitUp => Box::new(actions::pane::SplitAndMoveUp), + PaletteCommand::SplitDown => Box::new(actions::pane::SplitAndMoveDown), + PaletteCommand::MoveLeft => Box::new(actions::pane::MoveLeft), + PaletteCommand::MoveRight => Box::new(actions::pane::MoveRight), + PaletteCommand::MoveUp => Box::new(actions::pane::MoveUp), + PaletteCommand::MoveDown => Box::new(actions::pane::MoveDown), + PaletteCommand::JoinPane => Box::new(actions::pane::JoinIntoNext), + PaletteCommand::FocusLeft => Box::new(actions::workspace::ActivatePaneLeft), + PaletteCommand::FocusRight => Box::new(actions::workspace::ActivatePaneRight), + PaletteCommand::FocusUp => Box::new(actions::workspace::ActivatePaneUp), + PaletteCommand::FocusDown => Box::new(actions::workspace::ActivatePaneDown), + PaletteCommand::ToggleZoom => Box::new(actions::workspace::ToggleZoom), + PaletteCommand::OpenSettings => Box::new(actions::settings::Open), + }; + window.dispatch_action(action, cx); + window.focus(&self.focus, cx); + cx.notify(); + } + + fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { + if self.rename_space.is_some() { + cx.stop_propagation(); + match event.keystroke.key.as_str() { + "escape" => { + self.rename_space = None; + self.rename_query.clear(); + } + "enter" => { + self.commit_space_rename(cx); + return; + } + "backspace" => { + self.rename_query.pop(); + } + _ if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.platform + && !event.keystroke.modifiers.alt => + { + if let Some(text) = event.keystroke.key_char.as_deref() { + self.rename_query.push_str(text); + } + } + _ => {} + } + cx.notify(); + return; } - } - - /// Redraw whenever any session's reader says something changed. - /// - /// Every wakeup already waiting is drained before the redraw, so a burst of - /// frames costs one paint rather than one paint each. - fn watch(mut wakeups: mpsc::UnboundedReceiver<()>, cx: &mut Context) -> Task<()> { - cx.spawn(async move |this, cx| { - while wakeups.next().await.is_some() { - while wakeups.try_recv().is_ok() {} - if this.update(cx, |_, cx| cx.notify()).is_err() { - return; + if let Some(action) = self.recording_keymap { + cx.stop_propagation(); + if event.keystroke.key == "escape" { + self.recording_keymap = None; + cx.notify(); + return; + } + if matches!( + event.keystroke.key.as_str(), + "shift" | "control" | "alt" | "cmd" | "super" | "fn" + ) { + return; + } + let key = event.keystroke.unparse(); + match self.keymap.set(action, key) { + Ok(()) => { + self.recording_keymap = None; + self.keymap_restart_required = true; + self.problem = None; } + Err(error) => self.problem = Some(error.to_string()), } - }) - } - - /// Bring the private backend up and adopt whatever is already running in - /// this directory. - fn connect(&mut self, cwd: PathBuf, cx: &mut Context) { - if let Err(err) = self.client.connect(BACKEND_TIMEOUT) { - self.problem = Some(err.to_string()); + cx.notify(); return; } - - let label = cwd.file_name().map(|name| name.to_string_lossy().into_owned()); - match self.client.open_workspace(&cwd, label.as_deref()) { - Ok(workspace) => { - self.workspace = Some(workspace); - self.refresh(cx); + if self.command_palette_open { + cx.stop_propagation(); + let key = event.keystroke.key.as_str(); + match key { + "escape" => { + self.command_palette_open = false; + self.command_palette_query.clear(); + } + "backspace" => { + self.command_palette_query.pop(); + self.command_palette_selected = 0; + } + "up" => { + let count = self.filtered_palette_commands().len(); + if count > 0 { + self.command_palette_selected = + self.command_palette_selected.checked_sub(1).unwrap_or(count - 1); + } + } + "down" => { + let count = self.filtered_palette_commands().len(); + if count > 0 { + self.command_palette_selected = (self.command_palette_selected + 1) % count; + } + } + "enter" => { + if let Some((command, _, _)) = + self.filtered_palette_commands().get(self.command_palette_selected).copied() + { + self.invoke_palette_command(command, window, cx); + return; + } + } + _ if !event.keystroke.modifiers.control + && !event.keystroke.modifiers.platform + && !event.keystroke.modifiers.alt => + { + if let Some(text) = event.keystroke.key_char.as_deref() { + self.command_palette_query.push_str(text); + self.command_palette_selected = 0; + } + } + _ => {} + } + cx.notify(); + return; + } + if self.settings_open { + if event.keystroke.modifiers.control && event.keystroke.key == "tab" { + cx.stop_propagation(); + self.cycle_settings_page(event.keystroke.modifiers.shift, cx); } - Err(err) => self.problem = Some(err.to_string()), + return; + } + let Some(bytes) = keys::bytes_for(&event.keystroke) else { + return; + }; + if let Some(space) = self.active.clone() { + space.update(cx, |space, cx| space.send_active(&bytes, cx)); } } - /// Attach to every session the backend is running that zeddy is not showing - /// yet. - /// - /// Adopting rather than creating is the point of a durable backend: a - /// session that outlived the last launch is picked up here, not restarted. - fn refresh(&mut self, cx: &mut Context) { - let known = self.client.sessions(self.workspace.as_ref()); - let listed = match known { - Ok(listed) => listed, - Err(err) => { - self.problem = Some(err.to_string()); - return; + fn space_switcher(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let current = self + .active + .as_ref() + .map(|space| space.read(cx).name().to_owned()) + .unwrap_or_else(|| "No space".to_owned()); + let active_id = self.active.as_ref().map(Entity::entity_id); + let weak = cx.weak_entity(); + let spaces: Vec<_> = self + .spaces + .iter() + .map(|space| { + let read = space.read(cx); + (space.clone(), read.name().to_owned(), read.kind()) + }) + .collect(); + + let menu = ContextMenu::build(window, cx, move |menu, _, _| { + let add = weak.clone(); + let mut menu = menu.entry("New space…", None, move |_, cx| { + let _ = add.update(cx, |this, cx| this.pick_a_folder(cx)); + }); + + for (space, name, _kind) in + spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::AdHoc) + { + let target = space.clone(); + let select = weak.clone(); + menu = menu.toggleable_entry( + name.clone(), + active_id == Some(space.entity_id()), + IconPosition::Start, + None, + move |window, cx| { + let _ = + select.update(cx, |this, cx| this.activate(target.clone(), window, cx)); + }, + ); } - }; - for info in listed { - if self.sessions.iter().any(|session| session.id() == &info.id) { - continue; + let registered: Vec<_> = + spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::Registered).collect(); + if !registered.is_empty() { + menu = menu.separator(); } - match Session::attach(&self.client, info, self.grid(), self.wakeup_tx.clone()) { - Ok(session) => self.sessions.push(session), - Err(err) => self.problem = Some(err.to_string()), + for (space, name, _) in registered { + let target = space.clone(); + let select = weak.clone(); + menu = menu.toggleable_entry( + name.clone(), + active_id == Some(space.entity_id()), + IconPosition::Start, + None, + move |window, cx| { + let _ = + select.update(cx, |this, cx| this.activate(target.clone(), window, cx)); + }, + ); } - } + menu + }); - if matches!(self.showing, Showing::Empty) && !self.sessions.is_empty() { - self.showing = Showing::Session(0); - } - cx.notify(); + DropdownMenu::new("space-switcher", current, menu) + .style(DropdownStyle::Ghost) + .full_width(true) + .attach(Anchor::BottomLeft) + .aria_label("Current space") + .into_any_element() } - /// The grid the last paint found room for, or a sane default before the - /// first one. - fn grid(&self) -> Size { - self.fit.get().unwrap_or_default() + fn new_item_menu(&mut self, cx: &mut Context) -> AnyElement { + let weak = cx.weak_entity(); + let panes: Vec<_> = self.catalog.panes().into_iter().cloned().collect(); + PopoverMenu::new("new-item-menu") + .trigger_with_tooltip( + IconButton::new("new-item", IconName::Plus).icon_size(IconSize::Small), + Tooltip::text("New…"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let weak = weak.clone(); + let panes = panes.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + let start = weak.clone(); + let mut menu = menu.entry("New session", None, move |window, cx| { + let _ = start.update(cx, |this, cx| this.act(Action::New, window, cx)); + }); + if !panes.is_empty() { + menu = menu.separator(); + } + for pane in &panes { + let open = weak.clone(); + let key = pane.key.clone(); + menu = menu.entry(pane.title.clone(), None, move |window, cx| { + let _ = open + .update(cx, |this, cx| this.open_plugin(key.clone(), window, cx)); + }); + } + menu + })) + }) + .into_any_element() } - /// Tell the shown session how many cells the last paint found room for. - /// - /// Only the shown one: a background session has no bounds of its own, and - /// resizing it to the visible pane's grid would reflow a screen nobody is - /// looking at. It is resized when it is next shown. - fn fit_shown(&mut self) { - let Some(size) = self.fit.get() else { + fn open_plugin( + &mut self, + key: zeddy_plugin::PaneKey, + window: &mut Window, + cx: &mut Context, + ) { + let title = + self.catalog.panes().iter().find(|pane| pane.key == key).map(|pane| pane.title.clone()); + let Some(title) = title else { + self.problem = Some("That plugin contribution is no longer available.".to_owned()); + cx.notify(); return; }; - let Showing::Session(index) = self.showing else { + let (capabilities, permissions) = match self.catalog.get(&key.plugin) { + Some(plugin) => (plugin.capabilities().clone(), plugin.permissions().clone()), + None => { + self.problem = Some("That plugin is no longer loaded.".to_owned()); + cx.notify(); + return; + } + }; + let Some(space) = self.active.clone() else { return; }; - if let Some(session) = self.sessions.get_mut(index) - && let Err(err) = session.resize(size) + let bound_session = if capabilities.session_binding { + let Some(session) = space.read(cx).active_session_id() else { + self.problem = + Some("Select a terminal before opening this session-bound plugin.".to_owned()); + cx.notify(); + return; + }; + Some(session) + } else { + None + }; + if capabilities.multiplicity == Multiplicity::PerSpace + && space.update(cx, |space, _| space.activate_plugin(&key)) { - self.problem = Some(err.to_string()); - } - } - - fn act(&mut self, action: Action, cx: &mut Context) { - match action { - Action::ToggleMode => self.mode = self.mode.toggled(), - Action::Select(index) => self.showing = Showing::Session(index), - Action::New => self.start_session(cx), - Action::Close(index) => self.close_session(index, cx), + self.problem = None; + cx.notify(); + return; } + let project = + (space.read(cx).kind() == SpaceKind::Registered).then(|| space.read(cx).path().clone()); + let instance = InstanceContext { + space: space.read(cx).key(), + project_dir: project.clone(), + bound_session: bound_session.as_ref().map(|session| session.0.clone()), + }; + let session_access = + bound_session.as_ref().and_then(|session| space.read(cx).session_access(session)); + let unsafe_filesystem = self.settings.resolved().plugin(&key.plugin).unsafe_filesystem; + let Some(plugin) = self.catalog.get_mut(&key.plugin) else { + self.problem = Some("That plugin is no longer loaded.".to_owned()); + cx.notify(); + return; + }; + let view = match plugin.pane(&key) { + Some(PaneSource::Native(plugin)) => plugin.view(&key, &instance, window, cx), + Some(PaneSource::Web(entry)) => { + let broker = FileBroker::new( + project, + plugin_paths().data.join(&key.plugin), + permissions.project_files, + unsafe_filesystem, + ); + crate::web_plugin::view( + entry.to_path_buf(), + broker, + permissions.clone(), + session_access, + window, + cx, + ) + } + None => { + self.problem = Some("That pane is no longer contributed.".to_owned()); + cx.notify(); + return; + } + }; + space.update(cx, |space, cx| { + space.open_plugin( + PluginItem { + contribution: key, + title, + view, + bound_session, + can_clone: capabilities.cloneable, + restorable: capabilities.restorable, + }, + cx, + ); + }); + self.problem = None; cx.notify(); } - fn start_session(&mut self, cx: &mut Context) { - let Some(workspace) = self.workspace.clone() else { + fn restore_plugins_once(&mut self, window: &mut Window, cx: &mut Context) { + if self.plugins_restored { return; - }; - match self.client.start_session(&workspace, None) { - Ok(info) => { - match Session::attach(&self.client, info, self.grid(), self.wakeup_tx.clone()) { - Ok(session) => { - self.sessions.push(session); - self.showing = Showing::Session(self.sessions.len() - 1); - self.problem = None; + } + if matches!(self.backend, Backend::Starting | Backend::Recovering(_)) { + return; + } + self.plugins_restored = true; + let mut failures = Vec::new(); + for space in self.spaces.clone() { + let records = space.update(cx, |space, _| space.take_restoring_plugins()); + for record in &records { + let crate::persistence::PersistedItem::Plugin { + plugin, pane, bound_session, .. + } = record + else { + continue; + }; + let key = zeddy_plugin::PaneKey { plugin: plugin.clone(), key: pane.clone() }; + let descriptor = self.catalog.get(plugin).and_then(|loaded| { + loaded.panes.iter().find(|candidate| candidate.key == key).map(|candidate| { + ( + candidate.title.clone(), + loaded.capabilities().clone(), + loaded.permissions().clone(), + ) + }) + }); + let Some((title, capabilities, permissions)) = descriptor else { + failures.push(format!("{plugin}:{pane} is unavailable")); + continue; + }; + if !capabilities.restorable { + failures.push(format!("{plugin}:{pane} does not support restoration")); + continue; + } + let project = (space.read(cx).kind() == SpaceKind::Registered) + .then(|| space.read(cx).path().clone()); + let unsafe_filesystem = self.settings.resolved().plugin(plugin).unsafe_filesystem; + let bound = bound_session.clone().map(zeddy_herdr::PaneId); + let session_access = + bound.as_ref().and_then(|session| space.read(cx).session_access(session)); + if bound.is_some() && session_access.is_none() { + failures.push(format!("{plugin}:{pane} lost its bound session")); + continue; + } + let instance = InstanceContext { + space: space.read(cx).key(), + project_dir: project.clone(), + bound_session: bound_session.clone(), + }; + let Some(loaded) = self.catalog.get_mut(plugin) else { + failures.push(format!("{plugin}:{pane} is disabled")); + continue; + }; + let view = match loaded.pane(&key) { + Some(PaneSource::Native(plugin)) => plugin.view(&key, &instance, window, cx), + Some(PaneSource::Web(entry)) => { + let broker = FileBroker::new( + project, + plugin_paths().data.join(plugin), + permissions.project_files, + unsafe_filesystem, + ); + crate::web_plugin::view( + entry.to_path_buf(), + broker, + permissions.clone(), + session_access, + window, + cx, + ) } - Err(err) => self.problem = Some(err.to_string()), + None => { + failures.push(format!("{plugin}:{pane} is no longer contributed")); + continue; + } + }; + let item = PluginItem { + contribution: key, + title, + view, + bound_session: bound, + can_clone: capabilities.cloneable, + restorable: true, + }; + if !space.update(cx, |space, cx| space.restore_plugin(record, item, cx)) { + failures.push(format!("{plugin}:{pane} had no saved layout item")); } } - Err(err) => self.problem = Some(err.to_string()), + space.update(cx, |space, _| space.remove_plugin_placeholders(&records)); + } + if !failures.is_empty() { + self.problem = + Some(format!("Some plugin items could not be restored: {}.", failures.join(", "))); } - cx.notify(); } - fn close_session(&mut self, index: usize, cx: &mut Context) { - if index >= self.sessions.len() { - return; + fn clone_plugin_drop( + &mut self, + space: Entity, + source_item: crate::workspace::ItemId, + target: LayoutPaneId, + index: Option, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some((key, title, bound_session)) = space.read(cx).cloneable_plugin(source_item) else { + return false; + }; + let Some(loaded) = self.catalog.get(&key.plugin) else { + return false; + }; + let capabilities = loaded.capabilities().clone(); + let permissions = loaded.permissions().clone(); + let project = + (space.read(cx).kind() == SpaceKind::Registered).then(|| space.read(cx).path().clone()); + let instance = InstanceContext { + space: space.read(cx).key(), + project_dir: project.clone(), + bound_session: bound_session.as_ref().map(|session| session.0.clone()), + }; + let session_access = + bound_session.as_ref().and_then(|session| space.read(cx).session_access(session)); + let unsafe_filesystem = self.settings.resolved().plugin(&key.plugin).unsafe_filesystem; + let Some(destination) = space.update(cx, |space, _| space.prepare_drop_destination(target)) + else { + return true; + }; + let Some(loaded) = self.catalog.get_mut(&key.plugin) else { + return false; + }; + let view = match loaded.pane(&key) { + Some(PaneSource::Native(plugin)) => plugin.view(&key, &instance, window, cx), + Some(PaneSource::Web(entry)) => crate::web_plugin::view( + entry.to_path_buf(), + FileBroker::new( + project, + plugin_paths().data.join(&key.plugin), + permissions.project_files, + unsafe_filesystem, + ), + permissions, + session_access, + window, + cx, + ), + None => return false, + }; + space.update(cx, |space, cx| { + space.open_plugin_in( + PluginItem { + contribution: key, + title, + view, + bound_session, + can_clone: capabilities.cloneable, + restorable: capabilities.restorable, + }, + destination, + index, + cx, + ); + }); + true + } + + fn workspace_pane(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(space) = self.active.clone() else { + return message("No space. Add a folder to begin.", cx).into_any_element(); + }; + let (problem, active) = space.update(cx, |space, _| { + space.fit_items(); + (space.problem().map(str::to_owned), space.active()) + }); + let on_action = + cx.listener(|this, action: &Action, window, cx| this.act(action.clone(), window, cx)); + let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); + let space = space.read(cx); + if active.is_some_and(|active| space.item(active).is_none()) { + return message("That item is gone.", cx).into_any_element(); + } + let pane_count = space.layout().center.panes().len(); + let weak = cx.weak_entity(); + let workspace = if let Some(maximized) = space.layout().center.maximized { + self.render_pane(&space, maximized, pane_count > 1, &emit, &weak, window, cx) + } else { + self.render_member( + &space, + &space.layout().center.root, + pane_count > 1, + &emit, + &weak, + &[], + window, + cx, + ) + }; + let notices = self.workspace_notices(problem, cx); + v_flex() + .size_full() + .min_h_0() + .children(notices) + .child(div().flex_1().min_h_0().child(workspace)) + .into_any_element() + } + + fn workspace_notices( + &mut self, + space_problem: Option, + cx: &mut Context, + ) -> Vec { + let mut notices = Vec::new(); + match self.backend.clone() { + Backend::Ready => {} + Backend::Starting => notices.push( + Banner::new() + .child(Label::new("Starting the terminal backend…").size(LabelSize::Small)) + .into_any_element(), + ), + Backend::Recovering(detail) => notices.push( + Banner::new() + .severity(Severity::Warning) + .child(Label::new(detail).size(LabelSize::Small)) + .into_any_element(), + ), + Backend::Failed(detail) => { + let retry = cx.listener(|this, _, _, cx| this.retry_backend(false, cx)); + let restart = + cx.listener(|this, _, window, cx| this.request_backend_restart(window, cx)); + notices.push( + Banner::new() + .severity(Severity::Error) + .wrap_content(true) + .child(Label::new(detail).size(LabelSize::Small)) + .action_slot( + h_flex() + .gap_1() + .child(Button::new("retry-backend", "Retry").on_click(retry)) + .child( + Button::new("restart-backend", "Restart Backend") + .on_click(restart), + ), + ) + .into_any_element(), + ); + } } - let mut session = self.sessions.remove(index); - if let Err(err) = self.client.close_session(session.id()) { - self.problem = Some(err.to_string()); + if let Some(problem) = self.problem.clone() { + notices.push( + Banner::new() + .severity(Severity::Warning) + .child(Label::new(problem).size(LabelSize::Small)) + .into_any_element(), + ); + } + if let Some(problem) = space_problem { + notices.push( + Banner::new() + .severity(Severity::Warning) + .child(Label::new(problem).size(LabelSize::Small)) + .into_any_element(), + ); } - session.release(); + notices + } - // Selection follows the list rather than the index: closing the tab you - // are on should land you on its neighbour, not on nothing. - self.showing = match self.showing.clone() { - Showing::Session(_) if self.sessions.is_empty() => Showing::Empty, - Showing::Session(selected) if selected > index => Showing::Session(selected - 1), - Showing::Session(selected) if selected == index => { - Showing::Session(index.min(self.sessions.len() - 1)) + fn render_member( + &self, + space: &Space, + member: &Member, + show_pane_headers: bool, + on: &chrome::Emit, + weak: &gpui::WeakEntity, + axis_path: &[usize], + window: &mut Window, + cx: &App, + ) -> AnyElement { + match member { + Member::Pane { pane } => { + self.render_pane(space, *pane, show_pane_headers, on, weak, window, cx) } - other => other, - }; - cx.notify(); + Member::Axis(axis) => { + let member_count = axis.members.len(); + let children: Vec<_> = axis + .members + .iter() + .enumerate() + .map(|(index, member)| { + let flex = axis.flexes.get(index).copied().unwrap_or(1.).max(0.01); + let mut child_path = axis_path.to_vec(); + child_path.push(index); + let divider = DraggedPaneDivider { + axis_path: axis_path.to_vec(), + divider: index, + axis: axis.axis, + }; + div() + .relative() + .flex_grow(flex) + .flex_basis(relative(0.)) + .min_w_0() + .min_h_0() + .child(self.render_member( + space, + member, + show_pane_headers, + on, + weak, + &child_path, + window, + cx, + )) + .when(index + 1 < member_count, |child| { + child.child(pane_resize_handle(divider, axis.axis)) + }) + }) + .collect(); + let resize = weak.clone(); + let current_path = axis_path.to_vec(); + let axis_direction = axis.axis; + match axis.axis { + PaneAxisDirection::Horizontal => h_flex() + .id(format!("pane-axis-h-{current_path:?}")) + .size_full() + .min_w_0() + .min_h_0() + .gap_px() + .bg(cx.theme().colors().border) + .on_drag_move::(move |event, _, cx| { + let dragged = event.drag(cx).clone(); + if dragged.axis_path != current_path || dragged.axis != axis_direction { + return; + } + let fraction = (event.event.position.x - event.bounds.left()) + / event.bounds.size.width; + let _ = resize.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| { + space.resize_divider( + &dragged.axis_path, + dragged.divider, + fraction, + ) + }); + } + cx.notify(); + }); + }) + .children(children) + .into_any_element(), + PaneAxisDirection::Vertical => { + let resize = weak.clone(); + let current_path = axis_path.to_vec(); + v_flex() + .id(format!("pane-axis-v-{current_path:?}")) + .size_full() + .min_w_0() + .min_h_0() + .gap_px() + .bg(cx.theme().colors().border) + .on_drag_move::(move |event, _, cx| { + let dragged = event.drag(cx).clone(); + if dragged.axis_path != current_path + || dragged.axis != PaneAxisDirection::Vertical + { + return; + } + let fraction = (event.event.position.y - event.bounds.top()) + / event.bounds.size.height; + let _ = resize.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| { + space.resize_divider( + &dragged.axis_path, + dragged.divider, + fraction, + ) + }); + } + cx.notify(); + }); + }) + .children(children) + .into_any_element() + } + } + } + } } - /// The chrome's view of the sessions, plus the plugin panes that share the - /// same list. - fn entries(&self) -> Vec { - let selected_session = match self.showing { - Showing::Session(index) => Some(index), - _ => None, + fn render_pane( + &self, + space: &Space, + pane_id: LayoutPaneId, + show_header: bool, + on: &chrome::Emit, + weak: &gpui::WeakEntity, + window: &mut Window, + cx: &App, + ) -> AnyElement { + let Some(pane) = space.layout().pane(pane_id) else { + return message("Pane layout is unavailable.", cx).into_any_element(); }; + let active_pane = space.layout().active_pane() == pane_id; + let header = + if show_header { Some(self.pane_header(space, pane_id, on, weak, cx)) } else { None }; + let content = pane + .active() + .and_then(|id| space.item(id).map(|item| (id, item))) + .map(|(id, item)| match item { + crate::item::Item::Session(item) => { + let terminal = terminal( + item, + active_pane && self.focus.is_focused(window), + self.settings.resolved(), + cx, + ) + .into_any_element(); + let ended = item.session.ended(); + let retrying = space.reattaching(id); + let retry = weak.clone(); + v_flex() + .relative() + .size_full() + .child(terminal) + .when_some(ended, |view, ended| { + let detail = match &ended { + crate::session::Ended::Closed => { + "Session ended. Close this tab when you are done reviewing it." + .to_owned() + } + crate::session::Ended::Failed(error) => { + format!("Terminal connection failed: {error}") + } + }; + view.child( + div().absolute().left_2().right_2().bottom_2().child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(detail).size(LabelSize::Small)) + .when( + matches!(ended, crate::session::Ended::Failed(_)), + |banner| { + banner.action_slot( + Button::new( + format!("reattach-session-{}", id.get()), + if retrying { + "Reattaching…" + } else { + "Reattach" + }, + ) + .disabled(retrying) + .on_click(move |_, _, cx| { + let _ = retry.update(cx, |this, cx| { + if let Some(space) = this.active.clone() + { + space.update(cx, |space, cx| { + space.reattach(id, cx) + }); + } + }); + }), + ) + }, + ), + ), + ) + }) + .into_any_element() + } + crate::item::Item::Plugin(item) => item.view.clone().into_any_element(), + }) + .unwrap_or_else(|| { + message("Drop a tab here or create a new item.", cx).into_any_element() + }); - let mut entries: Vec = self - .sessions - .iter() - .enumerate() - .map(|(index, session)| Entry { - title: session.title(), - agent: session.info.agent.clone(), - ended: session.ended().is_some(), - selected: selected_session == Some(index), + let drag_move = weak.clone(); + let drop_item = weak.clone(); + let drop_overlay = space.drag_target().filter(|(pane, _)| *pane == pane_id); + v_flex() + .id(("pane", pane_id.get() as usize)) + .relative() + .size_full() + .min_w_0() + .min_h_0() + .bg(cx.theme().colors().editor_background) + .when(active_pane, |pane| { + pane.border_1().border_color(cx.theme().colors().pane_focused_border) }) - .collect(); + .on_drag_move::(move |event, _, cx| { + let direction = split_direction_for_drag(event); + let _ = drag_move.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| space.set_drag_target(pane_id, direction)); + } + cx.notify(); + }); + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + let dragged = dragged.clone(); + let _ = drop_item.update(cx, |this, cx| { + let Some(space) = this.active.clone() else { + return; + }; + if space.read(cx).persisted().key != dragged.space { + return; + } + let clone = cfg!(target_os = "macos") && window.modifiers().alt + || cfg!(not(target_os = "macos")) && window.modifiers().control; + if clone + && this.clone_plugin_drop( + space.clone(), + dragged.item, + pane_id, + None, + window, + cx, + ) + { + cx.notify(); + return; + } + space.update(cx, |space, _| { + space.drop_item(dragged.item, dragged.pane, pane_id, None) + }); + cx.notify(); + }); + }) + .children(header) + .child(div().flex_1().min_h_0().min_w_0().child(content)) + .when_some(drop_overlay, |pane, (_, direction)| pane.child(drop_target(direction, cx))) + .into_any_element() + } - // Plugin panes sit after the sessions, in the catalog's stable order, - // so a plugin cannot change where a session's tab is. - for pane in self.catalog.panes() { - entries.push(Entry { - title: pane.title.clone(), - agent: None, - ended: false, - selected: self.showing == Showing::Plugin(pane.key.clone()), - }); + fn pane_header( + &self, + space: &Space, + pane_id: LayoutPaneId, + on: &chrome::Emit, + weak: &gpui::WeakEntity, + cx: &App, + ) -> AnyElement { + let Some(pane) = space.layout().pane(pane_id) else { + return div().into_any_element(); + }; + let focus_pane = weak.clone(); + if self.mode == Mode::Sidebar { + return h_flex() + .id(("sidebar-pane-header", pane_id.get())) + .role(Role::Group) + .aria_label(format!("Pane {}", pane_id.get())) + .group("pane-header") + .h(Tab::container_height(cx)) + .px_2() + .justify_between() + .bg(cx.theme().colors().tab_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .on_click(move |_, _, cx| { + let _ = focus_pane.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| space.activate_pane(pane_id)); + } + cx.notify(); + }); + }) + .child( + Label::new(format!("Pane {}", pane_id.get())) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child( + Label::new(format!( + "{} tab{}", + pane.items().len(), + if pane.items().len() == 1 { "" } else { "s" } + )) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + div() + .visible_on_hover("pane-header") + .child(pane_controls(weak, pane_id)), + ), + ) + .into_any_element(); } - entries + + let active_index = + pane.active().and_then(|active| pane.items().iter().position(|item| *item == active)); + let space_key = space.persisted().key; + let tabs = pane.items().iter().enumerate().filter_map(|(index, id)| { + let item = space.item(*id)?; + let selected = pane.active() == Some(*id); + let position = if index == 0 { + TabPosition::First + } else if index + 1 == pane.items().len() { + TabPosition::Last + } else { + TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) + }; + let select = *id; + let close = *id; + let select_item = on.clone(); + let close_item = on.clone(); + let drop_item = weak.clone(); + let dragged = DraggedItem { + space: space_key.clone(), + space_entity: None, + pane: pane_id, + item: *id, + title: item.title(), + }; + Some( + Tab::new(format!("pane-{}-item-{}", pane_id.get(), id.get())) + .role(Role::Tab) + .aria_label(item.title()) + .aria_selected(selected) + .position(position) + .toggle_state(selected) + .on_click(move |_, window, cx| { + select_item(Action::Select { space: None, item: select }, window, cx) + }) + .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .on_drop(move |dragged: &DraggedItem, window, cx| { + let dragged = dragged.clone(); + let _ = drop_item.update(cx, |this, cx| { + let Some(space) = this.active.clone() else { + return; + }; + if space.read(cx).persisted().key != dragged.space { + return; + } + let clone = cfg!(target_os = "macos") && window.modifiers().alt + || cfg!(not(target_os = "macos")) && window.modifiers().control; + if clone + && this.clone_plugin_drop( + space.clone(), + dragged.item, + pane_id, + Some(index), + window, + cx, + ) + { + cx.notify(); + return; + } + space.update(cx, |space, _| { + space.set_drag_target(pane_id, None); + space.drop_item(dragged.item, dragged.pane, pane_id, Some(index)); + }); + cx.notify(); + }); + }) + .end_slot( + IconButton::new( + format!("close-pane-{}-item-{}", pane_id.get(), id.get()), + IconName::Close, + ) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + close_item(Action::Close { space: None, item: close }, window, cx) + }), + ) + .child(Label::new(item.title()).size(LabelSize::Small).truncate()) + .into_any_element(), + ) + }); + TabBar::new(format!("pane-{}-tabs", pane_id.get())) + .children(tabs) + .end_child(pane_controls(weak, pane_id)) + .into_any_element() } - /// Map a chrome index back onto what it selects. The chrome counts one - /// list; this is where it becomes two. - fn showing_for(&self, index: usize) -> Showing { - if index < self.sessions.len() { - Showing::Session(index) - } else { - self.catalog - .panes() - .get(index - self.sessions.len()) - .map(|pane| Showing::Plugin(pane.key.clone())) - .unwrap_or(Showing::Empty) + fn command_palette(&mut self, cx: &mut Context) -> Option { + if !self.command_palette_open { + return None; } + let commands = self.filtered_palette_commands(); + if self.command_palette_selected >= commands.len() { + self.command_palette_selected = 0; + } + let selected = self.command_palette_selected; + let weak = cx.weak_entity(); + let rows: Vec<_> = commands + .into_iter() + .enumerate() + .map(|(index, (command, label, shortcut))| { + let choose = weak.clone(); + ListItem::new(("command-palette-item", index)) + .spacing(ListItemSpacing::Dense) + .toggle_state(index == selected) + .aria_role(gpui::Role::ListBoxOption) + .aria_label(label) + .when(!shortcut.is_empty(), |item| item.aria_keyshortcuts(shortcut)) + .when(index == selected, ListItem::aria_active_descendant) + .on_click(move |_, window, cx| { + let _ = choose.update(cx, |this, cx| { + this.invoke_palette_command(command, window, cx) + }); + }) + .child( + h_flex() + .w_full() + .justify_between() + .child(Label::new(label).size(LabelSize::Small)) + .when(!shortcut.is_empty(), |row| { + row.child( + Label::new(shortcut) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }), + ) + }) + .collect(); + let dismiss = cx.listener(|this, _, _, cx| { + this.command_palette_open = false; + this.command_palette_query.clear(); + cx.notify(); + }); + let query = if self.command_palette_query.is_empty() { + "Type a command…".to_owned() + } else { + self.command_palette_query.clone() + }; + let query_color = + if self.command_palette_query.is_empty() { Color::Muted } else { Color::Default }; + + Some( + div() + .id("command-palette-scrim") + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .bg(gpui::black().opacity(0.35)) + .on_mouse_down(gpui::MouseButton::Left, dismiss) + .child( + v_flex() + .id("command-palette") + .absolute() + .top(px(48.)) + .left(relative(0.5)) + .ml(px(-320.)) + .w(px(640.)) + .max_h(px(480.)) + .rounded_lg() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .shadow_lg() + .overflow_hidden() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + h_flex() + .h(px(42.)) + .px_3() + .gap_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new(query).size(LabelSize::Small).color(query_color)), + ) + .child( + v_flex() + .id("command-palette-results") + .role(gpui::Role::ListBox) + .aria_label("Commands") + .p_1() + .overflow_y_scroll() + .when(rows.is_empty(), |list| { + list.child(div().p_3().child( + Label::new("No matching commands").color(Color::Muted), + )) + }) + .children(rows), + ), + ) + .into_any_element(), + ) } - fn on_key(&mut self, event: &gpui::KeyDownEvent, cx: &mut Context) { - let Showing::Session(index) = self.showing else { - return; - }; - let Some(bytes) = keys::bytes_for(&event.keystroke) else { - return; - }; - if let Some(session) = self.sessions.get_mut(index) - && let Err(err) = session.send(&bytes) - { - self.problem = Some(err.to_string()); + fn rename_space_overlay(&mut self, cx: &mut Context) -> Option { + self.rename_space?; + let cancel_scrim = cx.listener(|this, _, _, cx| { + this.rename_space = None; + this.rename_query.clear(); cx.notify(); - } + }); + let cancel_button = cx.listener(|this, _, _, cx| { + this.rename_space = None; + this.rename_query.clear(); + cx.notify(); + }); + let save = cx.listener(|this, _, _, cx| this.commit_space_rename(cx)); + Some( + div() + .id("rename-space-scrim") + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .bg(gpui::black().opacity(0.35)) + .on_mouse_down(gpui::MouseButton::Left, cancel_scrim) + .child( + v_flex() + .id("rename-space-dialog") + .absolute() + .top(px(96.)) + .left(relative(0.5)) + .ml(px(-220.)) + .w(px(440.)) + .p_4() + .gap_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .shadow_lg() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(Label::new("Rename Space").size(LabelSize::Large)) + .child( + h_flex() + .h(px(36.)) + .px_2() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_focused) + .bg(cx.theme().colors().editor_background) + .child( + Label::new(if self.rename_query.is_empty() { + "Type a space name…".to_owned() + } else { + self.rename_query.clone() + }) + .size(LabelSize::Small) + .color( + if self.rename_query.is_empty() { + Color::Muted + } else { + Color::Default + }, + ), + ), + ) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new("cancel-space-rename", "Cancel") + .on_click(cancel_button), + ) + .child(Button::new("save-space-rename", "Rename").on_click(save)), + ), + ) + .into_any_element(), + ) } - fn workspace_pane(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - // The grid the previous frame measured reaches the backend here, one - // frame late by construction: nothing knows how many cells fit until - // something has been laid out in the space they have to fit in. - self.fit_shown(); + fn settings_workspace(&mut self, cx: &mut Context) -> AnyElement { + let close = cx.listener(|this, _, _, cx| { + this.settings_open = false; + this.plugin_settings = None; + cx.notify(); + }); + let selected = self.settings_page; + let navigation: Vec<_> = SettingsPage::ALL + .into_iter() + .map(|page| { + div() + .id(format!("settings-page-{}", page.slug())) + .role(Role::Tab) + .aria_label(page.title()) + .aria_selected(page == selected) + .mx_1() + .px_2() + .py_1() + .rounded_sm() + .cursor_pointer() + .when(page == selected, |row| { + row.bg(cx.theme().colors().element_selected) + .text_color(cx.theme().colors().text) + }) + .when(page != selected, |row| { + row.text_color(cx.theme().colors().text_muted) + .hover(|row| row.bg(cx.theme().colors().element_hover)) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.settings_page = page; + if page != SettingsPage::Plugins { + this.plugin_settings = None; + } + cx.notify(); + })) + .child(Label::new(page.title()).size(LabelSize::Small)) + }) + .collect(); + let content = self.settings_content(cx); - if let Some(problem) = self.problem.clone() { - return message(&problem, cx).into_any_element(); - } + v_flex() + .id("settings-workspace") + .size_full() + .min_h_0() + .bg(cx.theme().colors().background) + .child( + h_flex() + .h(Tab::container_height(cx)) + .px_3() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(Label::new("Settings").size(LabelSize::Small)) + .child( + IconButton::new("close-settings", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Settings")) + .on_click(close), + ), + ) + .child( + h_flex() + .flex_1() + .min_h_0() + .child( + v_flex() + .w(px(176.)) + .h_full() + .py_2() + .border_r_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().surface_background) + .child(div().px_3().py_1().child( + Label::new("Options").size(LabelSize::XSmall).color(Color::Muted), + )) + .children(navigation), + ) + .child(content), + ) + .into_any_element() + } - match self.showing.clone() { - Showing::Empty => message("No session. Press + to start one.", cx).into_any_element(), - Showing::Session(index) => match self.sessions.get_mut(index) { - Some(session) => { - terminal(session, self.fit.clone(), self.focus.is_focused(window), cx) + fn settings_content(&mut self, cx: &mut Context) -> AnyElement { + let page = self.settings_page; + let plugin_override = (page == SettingsPage::Plugins) + .then(|| self.plugin_settings.as_ref()) + .flatten() + .map(|(plugin, view)| { + let back = cx.listener(|this, _, _, cx| { + this.plugin_settings = None; + cx.notify(); + }); + v_flex() + .gap_3() + .child(Button::new("plugin-settings-back", "Back to plugins").on_click(back)) + .child(Label::new(plugin.clone()).size(LabelSize::XSmall).color(Color::Muted)) + .child(div().min_h(px(320.)).child(view.clone())) + .into_any_element() + }); + let body = if let Some(plugin_override) = plugin_override { + plugin_override + } else { + match page { + SettingsPage::General => { + let terminate = self.settings.resolved().terminate_sessions_on_exit; + let toggle = cx + .listener(move |this, _, _, cx| this.set_terminate_on_exit(!terminate, cx)); + v_flex() + .gap_4() + .child(Label::new("Chartr").size(LabelSize::Large)) + .child( + Label::new(format!( + "Version {} · configuration namespace chartr-zeddy", + env!("CARGO_PKG_VERSION") + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex() + .justify_between() + .gap_4() + .child( + v_flex() + .child( + Label::new("Terminate sessions on exit") + .size(LabelSize::Small), + ) + .child( + Label::new( + "Normal app exit detaches and leaves sessions running.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new( + "terminate-sessions-on-exit", + if terminate { "On" } else { "Off" }, + ) + .toggle_state(terminate) + .on_click(toggle), + ), + ) + .into_any_element() + } + SettingsPage::Appearance => { + let selected = self.settings.resolved().fixed_theme.clone(); + let mode = self.settings.resolved().theme_mode; + let dark = cx.listener(|this, _, _, cx| { + this.set_theme_preference(ThemeMode::Fixed, Some(CHARTR_DARK), cx) + }); + let light = cx.listener(|this, _, _, cx| { + this.set_theme_preference(ThemeMode::Fixed, Some(CHARTR_LIGHT), cx) + }); + let system = cx.listener(|this, _, _, cx| { + this.set_theme_preference(ThemeMode::System, None, cx) + }); + let font = cx.weak_entity(); + let smaller = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(-1., cx)); + let larger = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(1., cx)); + v_flex() + .gap_3() + .child(setting_label("Theme")) + .child( + h_flex() + .gap_2() + .child( + Button::new("theme-chartr-dark", CHARTR_DARK) + .toggle_state( + mode == ThemeMode::Fixed && selected == CHARTR_DARK, + ) + .on_click(dark), + ) + .child( + Button::new("theme-chartr-light", CHARTR_LIGHT) + .toggle_state( + mode == ThemeMode::Fixed && selected == CHARTR_LIGHT, + ) + .on_click(light), + ) + .child( + Button::new("theme-system", "System") + .toggle_state(mode == ThemeMode::System) + .on_click(system), + ), + ) + .child(setting_label("Interface font")) + .child( + h_flex() + .gap_1() + .child( + PopoverMenu::new("ui-font-menu") + .trigger( + Button::new( + "ui-font-family", + self.settings.resolved().ui_font_family.clone(), + ) + .end_icon(Icon::new(IconName::ChevronDown)), + ) + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build( + window, + cx, + move |menu, _, _| { + ["IBM Plex Sans", ".ZedSans", "System UI"] + .into_iter() + .fold(menu, |menu, family| { + let set = font.clone(); + menu.entry( + family, + None, + move |_, cx| { + let _ = set.update( + cx, + |this, cx| { + this.set_ui_font( + family.to_owned(), + cx, + ) + }, + ); + }, + ) + }) + }, + )) + }), + ) + .child( + IconButton::new("ui-font-smaller", IconName::Dash) + .tooltip(Tooltip::text("Decrease interface font size")) + .on_click(smaller), + ) + .child( + Label::new(format!( + "{} px", + self.settings.resolved().ui_font_size + )) + .size(LabelSize::Small), + ) + .child( + IconButton::new("ui-font-larger", IconName::Plus) + .tooltip(Tooltip::text("Increase interface font size")) + .on_click(larger), + ), + ) .into_any_element() } - None => message("That session is gone.", cx).into_any_element(), - }, - Showing::Plugin(key) => self.plugin_pane(&key, window, cx), - } - } - - /// Mount a plugin's pane. - /// - /// A native plugin's view is an ordinary GPUI view dropped straight into - /// this element tree — the same frame path as the terminal beside it. - fn plugin_pane( - &mut self, - key: &PaneKey, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let Some(plugin) = self.catalog.get_mut(&key.plugin) else { - return message("That plugin is no longer loaded.", cx).into_any_element(); - }; - match plugin.pane(key) { - Some(PaneSource::Native(plugin)) => plugin.view(key, window, cx).into_any_element(), - Some(PaneSource::Web(entry)) => { - // The webview host is the one piece of the web tier that is not - // written yet; until it is, the pane says so rather than - // pretending to be empty. - message(&format!("Web plugin panes are not hosted yet ({}).", entry.display()), cx) + SettingsPage::Terminal => { + let font = cx.weak_entity(); + let smaller = + cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(-1., cx)); + let larger = + cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(1., cx)); + let choose_directory = + cx.listener(|this, _, _, cx| this.pick_ad_hoc_directory(cx)); + let retry = cx.listener(|this, _, _, cx| this.retry_backend(false, cx)); + let restart = + cx.listener(|this, _, window, cx| this.request_backend_restart(window, cx)); + let backend = match &self.backend { + Backend::Ready => "Connected".to_owned(), + Backend::Starting => "Starting".to_owned(), + Backend::Recovering(detail) | Backend::Failed(detail) => detail.clone(), + }; + v_flex() + .gap_3() + .child(setting_label("Terminal font")) + .child( + h_flex() + .gap_1() + .child( + PopoverMenu::new("terminal-font-menu") + .trigger( + Button::new( + "terminal-font-family", + self.settings + .resolved() + .terminal_font_family + .clone(), + ) + .end_icon(Icon::new(IconName::ChevronDown)), + ) + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build( + window, + cx, + move |menu, _, _| { + ["IBM Plex Mono", "Lilex", ".ZedMono"] + .into_iter() + .fold(menu, |menu, family| { + let set = font.clone(); + menu.entry( + family, + None, + move |_, cx| { + let _ = set.update( + cx, + |this, cx| { + this.set_terminal_font( + family.to_owned(), + cx, + ) + }, + ); + }, + ) + }) + }, + )) + }), + ) + .child( + IconButton::new("terminal-font-smaller", IconName::Dash) + .tooltip(Tooltip::text("Decrease terminal font size")) + .on_click(smaller), + ) + .child( + Label::new(format!( + "{} px", + self.settings.resolved().terminal_font_size + )) + .size(LabelSize::Small), + ) + .child( + IconButton::new("terminal-font-larger", IconName::Plus) + .tooltip(Tooltip::text("Increase terminal font size")) + .on_click(larger), + ), + ) + .child(setting_label("Ad-hoc directory")) + .child( + Button::new( + "choose-ad-hoc-directory", + self.settings.resolved().ad_hoc_directory.as_ref().map_or_else( + || "Home directory".to_owned(), + |path| path.display().to_string(), + ), + ) + .on_click(choose_directory), + ) + .child(setting_value("Backend", backend)) + .child( + h_flex() + .gap_1() + .child( + Button::new("settings-retry-backend", "Retry").on_click(retry), + ) + .child( + Button::new("settings-restart-backend", "Restart Backend") + .on_click(restart), + ), + ) + .into_any_element() + } + SettingsPage::Hotkeys => { + let recording = self.recording_keymap; + let rows: Vec<_> = KeymapAction::ALL + .into_iter() + .map(|action| { + let capture = cx.listener(move |this, _, _, cx| { + this.recording_keymap = Some(action); + this.problem = None; + cx.notify(); + }); + h_flex() + .justify_between() + .gap_4() + .child(Label::new(action.title()).size(LabelSize::Small)) + .child( + Button::new( + format!("record-hotkey-{}", action.id()), + if recording == Some(action) { + "Press shortcut…".to_owned() + } else { + self.keymap.key(action).to_owned() + }, + ) + .toggle_state(recording == Some(action)) + .on_click(capture), + ) + }) + .collect(); + v_flex() + .gap_2() + .when_some(self.keymap.problem().map(str::to_owned), |view, problem| { + view.child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(problem).size(LabelSize::Small)), + ) + }) + .when(self.keymap_restart_required, |view| { + view.child(Banner::new().child( + Label::new( + "Shortcut changes are saved. Restart Chartr to rebuild the application keymap.", + ) + .size(LabelSize::Small), + )) + }) + .child( + Label::new( + "Click a shortcut, then press one key chord. Conflicts in the Chartr context are rejected.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .children(rows) .into_any_element() + } + SettingsPage::Plugins => { + let mut descriptors: Vec<_> = self + .catalog + .loaded + .values() + .map(|loaded| (loaded.manifest.clone(), true, loaded.has_settings)) + .chain( + self.catalog + .disabled + .values() + .map(|disabled| (disabled.manifest.clone(), false, false)), + ) + .collect(); + descriptors.sort_by(|(left, _, _), (right, _, _)| left.name.cmp(&right.name)); + let rows: Vec<_> = descriptors + .into_iter() + .map(|(manifest, enabled, has_settings)| { + let id = manifest.id.clone(); + let control_id = id.clone(); + let configured = self.settings.resolved().plugin(&id); + let toggle = cx.listener(move |this, _, _, cx| { + this.set_plugin_enabled(id.clone(), !enabled, cx) + }); + let trust = match manifest.kind { + zeddy_plugin::manifest::Kind::Native => { + "Native — fully trusted code".to_owned() + } + zeddy_plugin::manifest::Kind::Web => { + let project = match manifest.permissions.project_files { + zeddy_plugin::manifest::ProjectAccess::None => { + "no project files" + } + zeddy_plugin::manifest::ProjectAccess::Read => { + "read project files" + } + zeddy_plugin::manifest::ProjectAccess::ReadWrite => { + "read/write project files" + } + }; + let mut grants = vec![project.to_owned()]; + if !manifest.permissions.network.is_empty() { + grants.push(format!( + "network: {}", + manifest.permissions.network.join(", ") + )); + } + if manifest.permissions.process { + grants.push("process actions".to_owned()); + } + if manifest.permissions.session { + grants.push("bound-session actions".to_owned()); + } + format!("Web — {}", grants.join(" · ")) + } + }; + let unsafe_control = + (manifest.kind == zeddy_plugin::manifest::Kind::Web).then(|| { + let id = manifest.id.clone(); + let change = cx.listener(move |this, _, _, cx| { + this.set_plugin_unsafe( + id.clone(), + !configured.unsafe_filesystem, + cx, + ) + }); + Button::new( + format!("plugin-unsafe-{}", manifest.id), + if configured.unsafe_filesystem { + "Unsafe filesystem granted" + } else { + "Grant unsafe filesystem" + }, + ) + .toggle_state(configured.unsafe_filesystem) + .on_click(change) + }); + let configure = has_settings.then(|| { + let id = manifest.id.clone(); + Button::new(format!("plugin-settings-{}", manifest.id), "Configure") + .on_click(cx.listener(move |this, _, window, cx| { + this.open_plugin_settings(id.clone(), window, cx) + })) + }); + v_flex() + .gap_2() + .p_3() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + h_flex() + .justify_between() + .child( + v_flex() + .child( + Label::new(manifest.name) + .size(LabelSize::Small), + ) + .child( + Label::new(manifest.id) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new( + format!("plugin-enabled-{control_id}"), + if enabled { "Enabled" } else { "Disabled" }, + ) + .toggle_state(enabled) + .on_click(toggle), + ), + ) + .child( + Label::new(trust).size(LabelSize::XSmall).color(Color::Muted), + ) + .when_some(configure, |row, control| row.child(control)) + .when_some(unsafe_control, |row, control| row.child(control)) + }) + .collect(); + let rejected: Vec<_> = self + .catalog + .rejected + .iter() + .map(|rejected| { + Banner::new().severity(Severity::Error).child( + Label::new(format!("{}: {}", rejected.dir.display(), rejected.why)) + .size(LabelSize::XSmall), + ) + }) + .collect(); + v_flex() + .gap_2() + .when(rows.is_empty() && rejected.is_empty(), |view| { + view.child(Label::new("No plugins installed.").color(Color::Muted)) + }) + .children(rows) + .children(rejected) + .into_any_element() + } } - None => message("That pane is no longer contributed.", cx).into_any_element(), - } + }; + v_flex() + .id(format!("settings-content-{}", page.slug())) + .flex_1() + .min_w_0() + .h_full() + .overflow_y_scroll() + .items_center() + .child( + v_flex() + .w_full() + .max_w(px(680.)) + .p_6() + .gap_5() + .child(Label::new(page.title()).size(LabelSize::Large)) + .when_some(self.settings.unreadable().map(str::to_owned), |view, error| { + view.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .child(body), + ) + .into_any_element() } } @@ -370,68 +3206,356 @@ impl Focusable for Zeddy { impl Render for Zeddy { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let entries = self.entries(); - // Copied out rather than borrowed: `cx.theme()` borrows `cx`, and - // building the workspace pane below needs it back. + let bounds = match window.window_bounds() { + gpui::WindowBounds::Windowed(bounds) + | gpui::WindowBounds::Maximized(bounds) + | gpui::WindowBounds::Fullscreen(bounds) => bounds, + }; + self.window_bounds = Some(crate::persistence::WindowBounds { + x: bounds.origin.x / px(1.), + y: bounds.origin.y / px(1.), + width: bounds.size.width / px(1.), + height: bounds.size.height / px(1.), + }); + self.restore_plugins_once(window, cx); + self.persist_if_changed(cx); + let entries = self.entries(cx); + let sidebar_spaces = self.sidebar_spaces(cx); + let pane_count = self + .active_space() + .map(|space| space.read(cx).layout().center.panes().len()) + .unwrap_or(0); + let chrome_entries: &[Entry] = + if self.mode == Mode::Tabs && pane_count > 1 { &[] } else { &entries }; + let switcher = self.space_switcher(window, cx); + let new_item = self.new_item_menu(cx); let (background, text, workspace_background) = { let colors = cx.theme().colors(); (colors.background, colors.text, colors.editor_background) }; - let on_action = cx.listener(|this, action: &Action, _, cx| { - let action = *action; - if let Action::Select(index) = action { - this.showing = this.showing_for(index); - cx.notify(); - } else { - this.act(action, cx); - } - }); + let on_action = + cx.listener(|this, action: &Action, window, cx| this.act(action.clone(), window, cx)); let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); - // `h_full` is not redundant with `flex_1`. In sidebar mode this sits in - // a row, where `flex_1` decides the *width* and the height would - // otherwise be the content's — which is a terminal that sizes itself to - // its parent, so the pair resolves to nothing at all. - let workspace = v_flex() - .flex_1() - .h_full() - .overflow_hidden() - .bg(workspace_background) - .child(self.workspace_pane(window, cx)); + let workspace = + v_flex().flex_1().h_full().overflow_hidden().bg(workspace_background).child( + if self.settings_open { + self.settings_workspace(cx) + } else { + self.workspace_pane(window, cx) + }, + ); - let body = match self.mode { - Mode::Sidebar => h_flex() - .size_full() - .child(chrome::sidebar::render(&entries, emit.clone(), cx)) - .child(workspace), - Mode::Tabs => v_flex() + let body = if self.settings_open { + h_flex() .size_full() - .child(chrome::tabs::render(&entries, emit, cx)) - .child(workspace), + .child(chrome::sidebar::render( + &sidebar_spaces, + switcher, + new_item, + emit.clone(), + self.sidebar_width, + cx, + )) + .child(workspace) + .into_any_element() + } else { + match self.mode { + Mode::Sidebar => h_flex() + .size_full() + .child(chrome::sidebar::render( + &sidebar_spaces, + switcher, + new_item, + emit.clone(), + self.sidebar_width, + cx, + )) + .child(workspace) + .into_any_element(), + Mode::Tabs => v_flex() + .size_full() + .child(chrome::tabs::render(chrome_entries, switcher, new_item, emit, cx)) + .child(workspace) + .into_any_element(), + } }; + let command_palette = self.command_palette(cx); + let rename_space = self.rename_space_overlay(cx); + div() + .relative() .track_focus(&self.focus) - .key_context("Zeddy") + .key_context(if self.rename_space.is_some() { + "RenameSpace" + } else if self.command_palette_open { + "CommandPalette" + } else if self.settings_open { + "Chartr Settings" + } else { + "Chartr" + }) .size_full() .bg(background) .text_color(text) - .on_key_down(cx.listener(|this, event, _, cx| this.on_key(event, cx))) + .on_drag_move::(cx.listener( + |this, event: &DragMoveEvent, _, cx| { + this.sidebar_width = (event.event.position.x / px(1.)) + .clamp(chrome::sidebar::MIN_WIDTH, chrome::sidebar::MAX_WIDTH); + cx.notify(); + }, + )) + .on_action(cx.listener(|this, _: &actions::pane::CloseActiveItem, _, cx| { + this.close_active_item(cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::CloseAllItems, window, cx| { + this.request_close_active_pane(window, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveLeft, _, cx| { + this.split_and_move(SplitDirection::Left, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveRight, _, cx| { + this.split_and_move(SplitDirection::Right, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveUp, _, cx| { + this.split_and_move(SplitDirection::Up, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveDown, _, cx| { + this.split_and_move(SplitDirection::Down, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::MoveLeft, _, cx| { + this.move_active_to_pane(SplitDirection::Left, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::MoveRight, _, cx| { + this.move_active_to_pane(SplitDirection::Right, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::MoveUp, _, cx| { + this.move_active_to_pane(SplitDirection::Up, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::MoveDown, _, cx| { + this.move_active_to_pane(SplitDirection::Down, cx) + })) + .on_action(cx.listener(|this, _: &actions::pane::JoinIntoNext, _, cx| { + this.join_active_into_next(cx) + })) + .on_action(cx.listener(|this, _: &actions::workspace::ActivatePaneLeft, window, cx| { + this.activate_pane_in_direction(SplitDirection::Left, window, cx) + })) + .on_action(cx.listener( + |this, _: &actions::workspace::ActivatePaneRight, window, cx| { + this.activate_pane_in_direction(SplitDirection::Right, window, cx) + }, + )) + .on_action(cx.listener(|this, _: &actions::workspace::ActivatePaneUp, window, cx| { + this.activate_pane_in_direction(SplitDirection::Up, window, cx) + })) + .on_action(cx.listener(|this, _: &actions::workspace::ActivatePaneDown, window, cx| { + this.activate_pane_in_direction(SplitDirection::Down, window, cx) + })) + .on_action( + cx.listener(|this, _: &actions::workspace::ToggleZoom, _, cx| this.toggle_zoom(cx)), + ) + .on_action(cx.listener(|this, _: &actions::workspace::NewTerminal, window, cx| { + this.act(Action::New, window, cx) + })) + .on_action( + cx.listener(|this, _: &actions::settings::Open, _, cx| this.open_settings(cx)), + ) + .on_action(cx.listener(|this, _: &actions::command_palette::Toggle, window, cx| { + this.toggle_command_palette(window, cx) + })) + .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) .child(body) + .children(command_palette) + .children(rename_space) + } +} + +fn setting_label(label: &'static str) -> AnyElement { + Label::new(label).size(LabelSize::Small).color(Color::Muted).into_any_element() +} + +fn split_direction_for_drag(event: &DragMoveEvent) -> Option { + let bounds = event.bounds; + let size = bounds.size.width.min(bounds.size.height) * 0.25; + let x = event.event.position.x - bounds.left(); + let y = event.event.position.y - bounds.top(); + if x >= size && x <= bounds.size.width - size && y >= size && y <= bounds.size.height - size { + return None; + } + [ + (SplitDirection::Up, y), + (SplitDirection::Right, bounds.size.width - x), + (SplitDirection::Down, bounds.size.height - y), + (SplitDirection::Left, x), + ] + .into_iter() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(direction, _)| direction) +} + +fn drop_target(direction: Option, cx: &App) -> Div { + div() + .absolute() + .border_2() + .border_color(cx.theme().colors().drop_target_border) + .bg(cx.theme().colors().drop_target_background) + .map(|target| match direction { + None => target.top_0().right_0().bottom_0().left_0(), + Some(SplitDirection::Up) => target.top_0().left_0().right_0().h(relative(0.5)), + Some(SplitDirection::Down) => target.bottom_0().left_0().right_0().h(relative(0.5)), + Some(SplitDirection::Left) => target.top_0().left_0().bottom_0().w(relative(0.5)), + Some(SplitDirection::Right) => target.top_0().right_0().bottom_0().w(relative(0.5)), + }) +} + +fn pane_resize_handle(dragged: DraggedPaneDivider, axis: PaneAxisDirection) -> impl IntoElement { + div() + .id(format!("pane-divider-{:?}-{}", dragged.axis_path, dragged.divider)) + .absolute() + .when(axis == PaneAxisDirection::Horizontal, |handle| { + handle.right(px(-3.)).top_0().h_full().w(px(6.)).cursor_col_resize() + }) + .when(axis == PaneAxisDirection::Vertical, |handle| { + handle.bottom(px(-3.)).left_0().w_full().h(px(6.)).cursor_row_resize() + }) + .on_drag(dragged, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .occlude() +} + +fn pane_controls(weak: &gpui::WeakEntity, pane_id: LayoutPaneId) -> AnyElement { + let focus = weak.clone(); + let split = weak.clone(); + let join = weak.clone(); + let zoom = weak.clone(); + let close_all = weak.clone(); + + h_flex() + .id(("pane-controls", pane_id.get())) + .gap_0p5() + .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { + let _ = focus.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| space.activate_pane(pane_id)); + } + cx.notify(); + }); + }) + .child( + PopoverMenu::new(("pane-split-menu", pane_id.get())) + .trigger_with_tooltip( + IconButton::new(("pane-split", pane_id.get()), IconName::Split) + .icon_size(IconSize::XSmall), + Tooltip::text("Split Pane"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let split = split.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + let left = split.clone(); + let right = split.clone(); + let up = split.clone(); + let down = split.clone(); + menu.entry("Split Left", None, move |_, cx| { + let _ = left.update(cx, |this, cx| { + this.split_and_move(SplitDirection::Left, cx) + }); + }) + .entry("Split Right", None, move |_, cx| { + let _ = right.update(cx, |this, cx| { + this.split_and_move(SplitDirection::Right, cx) + }); + }) + .entry("Split Up", None, move |_, cx| { + let _ = up + .update(cx, |this, cx| this.split_and_move(SplitDirection::Up, cx)); + }) + .entry("Split Down", None, move |_, cx| { + let _ = down.update(cx, |this, cx| { + this.split_and_move(SplitDirection::Down, cx) + }); + }) + })) + }), + ) + .child( + IconButton::new(("pane-join", pane_id.get()), IconName::ListCollapse) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Join Pane Into Next")) + .on_click(move |_, _, cx| { + let _ = join.update(cx, |this, cx| this.join_active_into_next(cx)); + }), + ) + .child( + IconButton::new(("pane-zoom", pane_id.get()), IconName::Maximize) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Toggle Pane Zoom")) + .on_click(move |_, _, cx| { + let _ = zoom.update(cx, |this, cx| this.toggle_zoom(cx)); + }), + ) + .child( + IconButton::new(("pane-close-all", pane_id.get()), IconName::Close) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close All in Pane")) + .on_click(move |_, window, cx| { + let _ = + close_all.update(cx, |this, cx| this.request_close_active_pane(window, cx)); + }), + ) + .into_any_element() +} + +fn setting_value(label: &'static str, value: String) -> AnyElement { + v_flex() + .gap_1() + .child(setting_label(label)) + .child(Label::new(value).size(LabelSize::Small)) + .into_any_element() +} + +fn load_registry(cwd: &std::path::Path) -> (Option, Option) { + let file = match spaces::spaces_file() { + Ok(file) => file, + Err(error) => return (None, Some(error.to_string())), + }; + let mut registry = match Registry::load(file) { + Ok(registry) => registry, + Err(error) => return (None, Some(error.to_string())), + }; + // Launching zeddy in a folder is the command-line equivalent of Zed's + // `zed `: the opened project joins the persisted recent/space list. + let is_ad_hoc_home = std::env::home_dir().is_some_and(|home| spaces::same_path(&home, cwd)); + if !is_ad_hoc_home + && !registry.spaces().iter().any(|space| spaces::same_path(space.path(), cwd)) + && let Err(error) = registry.register(cwd) + { + return (Some(registry), Some(error.to_string())); } + (Some(registry), None) } -fn terminal(session: &Session, fit: Fit, focused: bool, cx: &App) -> impl IntoElement { +fn terminal( + item: &crate::item::SessionItem, + focused: bool, + settings: &ResolvedSettings, + cx: &App, +) -> impl IntoElement { let theme = cx.theme(); - let screen = session.screen(); + let screen = item.session.screen(); let colors = screen .rows .iter() .map(|row| row.iter().map(|cell| palette::cell_colors(cell, theme)).collect()) .collect(); - let (font, font_size, line_height) = Fonts::default().terminal(); + let (font, font_size, line_height) = Fonts::from_settings(settings).terminal(); let appearance = Appearance { font, font_size, @@ -440,7 +3564,13 @@ fn terminal(session: &Session, fit: Fit, focused: bool, cx: &App) -> impl IntoEl cursor: theme.colors().terminal_foreground, }; - v_flex().size_full().p_2().child(TerminalElement::new(screen, colors, appearance, focused, fit)) + v_flex().size_full().p_2().child(TerminalElement::new( + screen, + colors, + appearance, + focused, + item.fit.clone(), + )) } fn message(text: &str, cx: &App) -> impl IntoElement { @@ -459,30 +3589,5 @@ fn plugin_paths() -> Paths { .unwrap_or_else(|| { PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share") }); - Paths::under(root.join("zeddy")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn closing_the_selected_session_lands_on_its_neighbour() { - // The selection rule is arithmetic on indices, so it is tested as such - // rather than through a live backend. - let after = |selected: usize, closed: usize, remaining: usize| -> Showing { - match Showing::Session(selected) { - Showing::Session(_) if remaining == 0 => Showing::Empty, - Showing::Session(s) if s > closed => Showing::Session(s - 1), - Showing::Session(s) if s == closed => Showing::Session(closed.min(remaining - 1)), - other => other, - } - }; - - assert_eq!(after(1, 1, 2), Showing::Session(1), "the next one takes the index"); - assert_eq!(after(2, 2, 2), Showing::Session(1), "closing the last selects the new last"); - assert_eq!(after(2, 0, 2), Showing::Session(1), "closing before shifts the selection down"); - assert_eq!(after(0, 1, 2), Showing::Session(0), "closing after leaves it alone"); - assert_eq!(after(0, 0, 0), Showing::Empty, "closing the only one shows nothing"); - } + Paths::under(root.join("chartr-zeddy")) } diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index a922620f..d37dfb00 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -3,18 +3,24 @@ //! A chrome is a list of sessions with one of them selected. Sidebar mode draws //! that list down the left; tabs mode draws it across the top. Neither knows //! anything else about the app, which is what keeps the two implementations to -//! a screenful each: they take [`Entry`] values and emit indices. +//! a screenful each: they take [`Entry`] values and emit stable item keys. pub mod sidebar; pub mod tabs; use std::rc::Rc; +use crate::workspace::{ItemId, PaneId}; +use gpui::EntityId; use ui::prelude::*; /// One row in the sidebar, or one tab in the strip. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { + pub space: EntityId, + pub space_key: String, + pub key: ItemId, + pub pane: PaneId, pub title: String, /// The agent herdr believes is running, when it knows one. In sidebar mode /// this is a second line; in tabs mode there is no room and it is dropped. @@ -23,15 +29,39 @@ pub struct Entry { /// user's decision, not something that happens to them. pub ended: bool, pub selected: bool, + pub closable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpaceEntries { + pub id: EntityId, + pub name: String, + pub removable: bool, + pub available: bool, + pub panes: Vec, + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneEntries { + pub id: PaneId, + pub entries: Vec, } /// What the user did to the chrome. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { - Select(usize), - Close(usize), + Select { space: Option, item: ItemId }, + Close { space: Option, item: ItemId }, + ClosePane { space: EntityId, pane: PaneId }, + CloseSpace { space: EntityId }, + RenameSpace { space: EntityId }, + LocateSpace { space: EntityId }, + NewInSpace { space: EntityId }, + MoveToPane { space: EntityId, item: ItemId, source: PaneId, target: PaneId }, New, ToggleMode, + ToggleSidebarScope, } /// How a chrome reports what the user did. @@ -40,6 +70,37 @@ pub enum Action { /// and a `cx.listener` closure is not `Clone`. pub type Emit = Rc; +#[derive(Clone)] +pub struct DraggedSidebar; + +impl Render for DraggedSidebar { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +#[derive(Clone)] +pub struct DraggedItem { + pub space: String, + pub space_entity: Option, + pub pane: PaneId, + pub item: ItemId, + pub title: String, +} + +impl Render for DraggedItem { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .px_3() + .py_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .child(Label::new(self.title.clone()).size(LabelSize::Small)) + } +} + /// The dot that carries a session's state, in the one place both chromes agree /// on what it means. pub fn status_dot(entry: &Entry, cx: &App) -> impl IntoElement { diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 15c75229..b18fc3da 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -4,49 +4,251 @@ //! tab cannot hold — the agent's name under the title, and a close button that //! is not fighting the title for space — so this chrome shows them. +use gpui::{MouseButton, Role, deferred}; use ui::{Tooltip, prelude::*}; use super::Emit; -use super::{Action, Entry, status_dot}; +use super::{Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, status_dot}; /// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a /// preference to persist, a drag handle to hit-test, and a minimum to enforce, /// and none of that is what makes this mode useful. -pub const WIDTH: Pixels = px(220.); +pub const DEFAULT_WIDTH: f32 = 280.; +pub const MIN_WIDTH: f32 = 180.; +pub const MAX_WIDTH: f32 = 480.; -pub fn render(entries: &[Entry], on: Emit, cx: &App) -> impl IntoElement { +pub fn render( + spaces: &[SpaceEntries], + space_switcher: AnyElement, + new_item: AnyElement, + on: Emit, + width: f32, + cx: &App, +) -> impl IntoElement { let colors = cx.theme().colors(); + let mut groups = Vec::new(); + let mut index = 0; + for space in spaces { + let add = on.clone(); + let close = on.clone(); + let rename = on.clone(); + let locate = on.clone(); + let space_id = space.id; + let close_space = space.id; + let rename_space = space.id; + let locate_space = space.id; + groups.push( + h_flex() + .group("space-heading") + .px_2() + .pt_2() + .pb_1() + .justify_between() + .child(Label::new(space.name.clone()).size(LabelSize::XSmall).color(Color::Muted)) + .child( + h_flex() + .gap_px() + .child( + IconButton::new(("new-in-space", index), IconName::Plus) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("New session in this space")) + .on_click(move |_, window, cx| { + add(Action::NewInSpace { space: space_id }, window, cx) + }), + ) + .when(space.removable, |controls| { + controls.child( + IconButton::new(("rename-space", index), IconName::Pencil) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Rename Space")) + .on_click(move |_, window, cx| { + rename( + Action::RenameSpace { space: rename_space }, + window, + cx, + ) + }), + ) + }) + .when(!space.available, |controls| { + controls.child( + IconButton::new(("locate-space", index), IconName::FolderOpen) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Locate Space Folder")) + .on_click(move |_, window, cx| { + locate( + Action::LocateSpace { space: locate_space }, + window, + cx, + ) + }), + ) + }) + .when(space.removable, |controls| { + controls.child( + IconButton::new(("close-space", index), IconName::Close) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close Space")) + .on_click(move |_, window, cx| { + close(Action::CloseSpace { space: close_space }, window, cx) + }), + ) + }), + ) + .into_any_element(), + ); + if space.panes.is_empty() { + groups.push( + div() + .px_2() + .py_1() + .child(Label::new("No open tabs").size(LabelSize::XSmall).color(Color::Muted)) + .into_any_element(), + ); + continue; + } + for pane in &space.panes { + let count = pane.entries.len(); + let close = on.clone(); + let move_item = on.clone(); + let close_space = space.id; + let move_space = space.id; + let pane_id = pane.id; + groups.push( + h_flex() + .id(format!("sidebar-pane-drop-{}-{}", index, pane.id.get())) + .group("sidebar-pane-heading") + .px_2() + .py_1() + .justify_between() + .border_1() + .border_color(colors.border_variant) + .rounded_sm() + .on_drop(move |dragged: &DraggedItem, window, cx| { + if dragged.space_entity != Some(move_space) || dragged.pane == pane_id { + return; + } + move_item( + Action::MoveToPane { + space: move_space, + item: dragged.item, + source: dragged.pane, + target: pane_id, + }, + window, + cx, + ) + }) + .child( + Label::new(format!("Pane {}", pane.id.get())) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child( + Label::new(format!( + "{count} tab{}", + if count == 1 { "" } else { "s" } + )) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + div().visible_on_hover("sidebar-pane-heading").child( + IconButton::new( + ("close-sidebar-pane", pane.id.get()), + IconName::Close, + ) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close All in Pane")) + .on_click( + move |_, window, cx| { + close( + Action::ClosePane { + space: close_space, + pane: pane_id, + }, + window, + cx, + ) + }, + ), + ), + ), + ) + .into_any_element(), + ); + if pane.entries.is_empty() { + groups.push( + div() + .px_3() + .py_1() + .child( + Label::new("Empty pane — drop a tab here") + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ); + } + for entry in &pane.entries { + groups.push(row(index, entry, on.clone(), cx).into_any_element()); + index += 1; + } + } + } v_flex() - .w(WIDTH) + .id("spaces-sidebar") + .relative() + .w(px(width)) .flex_none() .h_full() .bg(colors.panel_background) .border_r_1() .border_color(colors.border) - .child(header(on.clone())) - .child(v_flex().id("sessions").flex_1().overflow_y_scroll().p_1().gap_px().children( - entries.iter().enumerate().map(|(index, entry)| row(index, entry, on.clone(), cx)), + .child(header(space_switcher, new_item, on.clone())) + .child(v_flex().id("sessions").flex_1().overflow_y_scroll().p_1().gap_px().children(groups)) + .child(deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .right(px(-3.)) + .top_0() + .h_full() + .w(px(6.)) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()), )) } -fn header(on: Emit) -> impl IntoElement { +fn header(space_switcher: AnyElement, new_item: AnyElement, on: Emit) -> impl IntoElement { let toggle = on.clone(); + let scope = on.clone(); h_flex() .h(px(36.)) .px_2() .gap_1() .justify_between() - .child(Label::new("Sessions").size(LabelSize::Small).color(Color::Muted)) + .child(div().min_w_0().flex_1().child(space_switcher)) .child( h_flex() .gap_px() + .child(new_item) .child( - IconButton::new("new-session", IconName::Plus) + IconButton::new("toggle-space-scope", IconName::ListTree) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("New session")) - .on_click(move |_, window, cx| on(Action::New, window, cx)), + .tooltip(Tooltip::text("Show all or active space")) + .on_click(move |_, window, cx| { + scope(Action::ToggleSidebarScope, window, cx) + }), ) .child( IconButton::new("toggle-mode", IconName::Tab) @@ -61,8 +263,22 @@ fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { let colors = cx.theme().colors(); let close = on.clone(); + let select = entry.key; + let close_key = entry.key; + let space = entry.space; + let close_space = entry.space; + let dragged = DraggedItem { + space: entry.space_key.clone(), + space_entity: Some(entry.space), + pane: entry.pane, + item: entry.key, + title: entry.title.clone(), + }; h_flex() .id(("session", index)) + .role(Role::Tab) + .aria_label(entry.title.clone()) + .aria_selected(entry.selected) .group("session") .h(px(38.)) .px_2() @@ -70,7 +286,10 @@ fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { .rounded_sm() .when(entry.selected, |row| row.bg(colors.element_selected)) .when(!entry.selected, |row| row.hover(|row| row.bg(colors.element_hover))) - .on_click(move |_, window, cx| on(Action::Select(index), window, cx)) + .on_click(move |_, window, cx| { + on(Action::Select { space: Some(space), item: select }, window, cx) + }) + .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) .child(status_dot(entry, cx)) .child( v_flex() @@ -83,13 +302,22 @@ fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { ) }), ) - .child( - // Revealed on hover so a list of ten sessions is ten titles rather - // than ten titles and ten buttons. - div().visible_on_hover("session").child( - IconButton::new(("close", index), IconName::Close) - .icon_size(IconSize::XSmall) - .on_click(move |_, window, cx| close(Action::Close(index), window, cx)), - ), - ) + .when(entry.closable, |row| { + row.child( + // Revealed on hover so a list of ten sessions is ten titles rather + // than ten titles and ten buttons. + div().visible_on_hover("session").child( + IconButton::new(("close", index), IconName::Close) + .icon_size(IconSize::XSmall) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + close( + Action::Close { space: Some(close_space), item: close_key }, + window, + cx, + ) + }), + ), + ) + }) } diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 7771931e..2dc50c5c 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -5,79 +5,95 @@ //! squeezed in — the dot still carries the state, and the title carries the //! identity. -use ui::{Tooltip, prelude::*}; +use gpui::Role; +use ui::{Tab, TabPosition, Tooltip, prelude::*}; use super::Emit; use super::{Action, Entry, status_dot}; -pub const HEIGHT: Pixels = px(32.); - -pub fn render(entries: &[Entry], on: Emit, cx: &App) -> impl IntoElement { +pub fn render( + entries: &[Entry], + space_switcher: AnyElement, + new_item: AnyElement, + on: Emit, + cx: &App, +) -> impl IntoElement { let colors = cx.theme().colors(); - let new = on.clone(); let toggle = on.clone(); + let active_index = entries.iter().position(|entry| entry.selected); h_flex() - .h(HEIGHT) + .h(Tab::container_height(cx)) .flex_none() .w_full() .bg(colors.tab_bar_background) .border_b_1() .border_color(colors.border) + .child( + div() + .w(px(super::sidebar::DEFAULT_WIDTH)) + .h_full() + .flex_none() + .border_r_1() + .border_color(colors.border) + .child(space_switcher), + ) .child(h_flex().id("tabs").flex_1().overflow_x_scroll().children( - entries.iter().enumerate().map(|(index, entry)| tab(index, entry, on.clone(), cx)), + entries.iter().enumerate().map(|(index, entry)| { + tab(index, entries.len(), active_index, entry, on.clone(), cx) + }), )) .child( - h_flex() - .px_1() - .gap_px() - .flex_none() - .child( - IconButton::new("new-session", IconName::Plus) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("New session")) - .on_click(move |_, window, cx| new(Action::New, window, cx)), - ) - .child( - IconButton::new("toggle-mode", IconName::Menu) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Switch to sidebar")) - .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), - ), + h_flex().px_1().gap_px().flex_none().child(new_item).child( + IconButton::new("toggle-mode", IconName::Menu) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Switch to sidebar")) + .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), + ), ) } -fn tab(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { - let colors = cx.theme().colors(); +fn tab( + index: usize, + count: usize, + active_index: Option, + entry: &Entry, + on: Emit, + cx: &App, +) -> impl IntoElement { let close = on.clone(); - - h_flex() - .id(("tab", index)) - .group("tab") - .h_full() - .px_2() - .gap_1p5() - .max_w(px(200.)) - .border_r_1() - .border_color(colors.border) - .when(entry.selected, |tab| tab.bg(colors.tab_active_background)) - .when(!entry.selected, |tab| { - tab.bg(colors.tab_inactive_background).hover(|tab| tab.bg(colors.element_hover)) + let position = if index == 0 { + TabPosition::First + } else if index + 1 == count { + TabPosition::Last + } else { + TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) + }; + let select = entry.key; + let close_key = entry.key; + let space = entry.space; + let close_space = entry.space; + let close_slot: Option = entry.closable.then(|| { + IconButton::new(("close", index), IconName::Close) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + close(Action::Close { space: Some(close_space), item: close_key }, window, cx) + }) + .into_any_element() + }); + Tab::new(("tab", index)) + .role(Role::Tab) + .aria_label(entry.title.clone()) + .aria_selected(entry.selected) + .position(position) + .toggle_state(entry.selected) + .on_click(move |_, window, cx| { + on(Action::Select { space: Some(space), item: select }, window, cx) }) - .on_click(move |_, window, cx| on(Action::Select(index), window, cx)) - .child(status_dot(entry, cx)) - .child( - Label::new(entry.title.clone()) - .size(LabelSize::Small) - .color(if entry.selected { Color::Default } else { Color::Muted }) - .truncate(), - ) - .child( - div().visible_on_hover("tab").child( - IconButton::new(("close", index), IconName::Close) - .icon_size(IconSize::XSmall) - .on_click(move |_, window, cx| close(Action::Close(index), window, cx)), - ), - ) + .start_slot(status_dot(entry, cx)) + .end_slot::(close_slot) + .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()) } diff --git a/crates/zeddy/src/fonts.rs b/crates/zeddy/src/fonts.rs index ff295166..7ef0e93c 100644 --- a/crates/zeddy/src/fonts.rs +++ b/crates/zeddy/src/fonts.rs @@ -6,52 +6,66 @@ //! the five questions itself — and this is then also the one place the terminal //! font is chosen, rather than a constant in the renderer. +use std::borrow::Cow; + use gpui::{App, Font, Pixels, px}; use theme::{ThemeSettingsProvider, UiDensity}; +use crate::settings::ResolvedSettings; + /// The families zeddy asks for, and the sizes it draws them at. pub struct Fonts { ui: Font, buffer: Font, + ui_size: Pixels, + buffer_size: Pixels, } -/// The UI face. GPUI resolves this to the platform's own system font. -const UI_FAMILY: &str = ".SystemUIFont"; +/// Chartr's typography defaults. IBM Plex Sans comes from Zed's asset bundle; +/// Mono is bundled below because Zed does not ship that face. +const UI_FAMILY: &str = "IBM Plex Sans"; +const MONOSPACE_FAMILY: &str = "IBM Plex Mono"; -/// The monospace face the terminal is drawn in: the one every one of these -/// platforms ships, so it is there without zeddy bundling a font file. -const MONOSPACE_FAMILY: &str = if cfg!(target_os = "macos") { - "Menlo" -} else if cfg!(target_os = "windows") { - "Consolas" -} else { - "DejaVu Sans Mono" -}; +const IBM_PLEX_MONO: &[u8] = + include_bytes!("../assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf"); + +pub fn load_bundled(cx: &App) -> anyhow::Result<()> { + cx.text_system().add_fonts(vec![Cow::Borrowed(IBM_PLEX_MONO)]) +} impl Default for Fonts { fn default() -> Self { - Self { ui: gpui::font(UI_FAMILY), buffer: gpui::font(MONOSPACE_FAMILY) } + Self { + ui: gpui::font(UI_FAMILY), + buffer: gpui::font(MONOSPACE_FAMILY), + ui_size: px(14.), + buffer_size: px(13.), + } } } impl Fonts { + pub fn from_settings(settings: &ResolvedSettings) -> Self { + Self { + ui: gpui::font(settings.ui_font_family.clone()), + buffer: gpui::font(settings.terminal_font_family.clone()), + ui_size: px(settings.ui_font_size), + buffer_size: px(settings.terminal_font_size), + } + } + /// The terminal's font and the line height to draw it at. /// /// The ratio is the one every terminal uses and nobody writes down: a line /// box about 1.4× the point size, which leaves box-drawing characters /// touching and leaves text legible. pub fn terminal(&self) -> (Font, Pixels, Pixels) { - let size = px(13.); + let size = self.buffer_size; (self.buffer.clone(), size, (size * 1.4).round()) } } -/// Whether the platform can actually rasterise text. -/// -/// GPUI answers `all_font_names` with its own hardcoded fallback list even when -/// the platform text system is the one that draws nothing, so "is the list -/// empty" is not the question. The question is whether a family the operating -/// system really ships is in it. +/// Whether the platform can actually rasterise the bundled terminal face. pub fn text_renders(cx: &App) -> bool { cx.text_system().all_font_names().iter().any(|name| name == MONOSPACE_FAMILY) } @@ -66,7 +80,7 @@ impl ThemeSettingsProvider for Fonts { } fn ui_font_size(&self, _: &App) -> Pixels { - px(14.) + self.ui_size } fn buffer_font_size(&self, _: &App) -> Pixels { @@ -93,4 +107,10 @@ mod tests { fn zeddy_names_a_family_on_every_platform() { assert!(!MONOSPACE_FAMILY.is_empty() && !UI_FAMILY.is_empty()); } + + #[test] + fn the_default_monospace_is_a_real_bundled_font() { + assert!(IBM_PLEX_MONO.starts_with(&[0, 1, 0, 0])); + assert!(IBM_PLEX_MONO.len() > 100_000); + } } diff --git a/crates/zeddy/src/item.rs b/crates/zeddy/src/item.rs new file mode 100644 index 00000000..719d393e --- /dev/null +++ b/crates/zeddy/src/item.rs @@ -0,0 +1,78 @@ +//! Runtime items owned by a Chartr pane. +//! +//! The workspace model owns stable [`ItemId`](crate::workspace::ItemId) values; +//! this module owns the corresponding live object. Catalog entries are not +//! items. Opening a plugin creates one `PluginItem`, just as attaching a Herdr +//! session creates one `SessionItem`. + +use gpui::AnyView; +use zeddy_plugin::PaneKey; + +use crate::{session::Session, terminal::Fit}; + +pub enum Item { + Session(SessionItem), + Plugin(PluginItem), +} + +impl Item { + pub fn title(&self) -> String { + match self { + Self::Session(item) => item.session.title(), + Self::Plugin(item) => item.title.clone(), + } + } + + pub fn agent(&self) -> Option { + match self { + Self::Session(item) => item.session.info.agent.clone(), + Self::Plugin(_) => None, + } + } + + pub fn ended(&self) -> bool { + matches!(self, Self::Session(item) if item.session.ended().is_some()) + } + + pub fn as_session(&self) -> Option<&SessionItem> { + match self { + Self::Session(item) => Some(item), + Self::Plugin(_) => None, + } + } + + pub fn as_session_mut(&mut self) -> Option<&mut SessionItem> { + match self { + Self::Session(item) => Some(item), + Self::Plugin(_) => None, + } + } + + pub fn as_plugin(&self) -> Option<&PluginItem> { + match self { + Self::Plugin(item) => Some(item), + Self::Session(_) => None, + } + } +} + +pub struct SessionItem { + pub session: Session, + pub fit: Fit, +} + +impl SessionItem { + pub fn new(session: Session) -> Self { + Self { session, fit: Fit::default() } + } +} + +pub struct PluginItem { + pub contribution: PaneKey, + pub title: String, + pub view: AnyView, + /// A session-specific plugin closes when this Herdr session ends. + pub bound_session: Option, + pub can_clone: bool, + pub restorable: bool, +} diff --git a/crates/zeddy/src/keymap.rs b/crates/zeddy/src/keymap.rs new file mode 100644 index 00000000..bcbecc5c --- /dev/null +++ b/crates/zeddy/src/keymap.rs @@ -0,0 +1,255 @@ +//! User-global Chartr keybindings. +//! +//! The editable file is sparse, like Zed's keymap: omitted actions keep their +//! platform default. Settings records one chord at a time, rejects conflicts +//! in the shared `Chartr` context, and writes atomically. Bindings are loaded at +//! launch; Settings says so rather than pretending GPUI can remove one binding +//! without rebuilding the application keymap. + +use std::{ + collections::BTreeMap, + fs, + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +pub const KEYMAP_FILE: &str = "keymap.toml"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum KeymapAction { + CloseItem, + NewTerminal, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ToggleZoom, + CommandPalette, + OpenSettings, +} + +impl KeymapAction { + pub const ALL: [Self; 9] = [ + Self::CloseItem, + Self::NewTerminal, + Self::FocusLeft, + Self::FocusRight, + Self::FocusUp, + Self::FocusDown, + Self::ToggleZoom, + Self::CommandPalette, + Self::OpenSettings, + ]; + + pub fn id(self) -> &'static str { + match self { + Self::CloseItem => "pane.close_active_item", + Self::NewTerminal => "workspace.new_terminal", + Self::FocusLeft => "workspace.activate_pane_left", + Self::FocusRight => "workspace.activate_pane_right", + Self::FocusUp => "workspace.activate_pane_up", + Self::FocusDown => "workspace.activate_pane_down", + Self::ToggleZoom => "workspace.toggle_zoom", + Self::CommandPalette => "command_palette.toggle", + Self::OpenSettings => "settings.open", + } + } + + pub fn title(self) -> &'static str { + match self { + Self::CloseItem => "Close active item", + Self::NewTerminal => "New terminal", + Self::FocusLeft => "Focus pane left", + Self::FocusRight => "Focus pane right", + Self::FocusUp => "Focus pane up", + Self::FocusDown => "Focus pane down", + Self::ToggleZoom => "Toggle pane zoom", + Self::CommandPalette => "Command palette", + Self::OpenSettings => "Open Settings", + } + } + + pub fn default_key(self) -> &'static str { + #[cfg(target_os = "macos")] + return match self { + Self::CloseItem => "cmd-w", + Self::NewTerminal => "ctrl-~", + Self::FocusLeft => "cmd-k cmd-left", + Self::FocusRight => "cmd-k cmd-right", + Self::FocusUp => "cmd-k cmd-up", + Self::FocusDown => "cmd-k cmd-down", + Self::ToggleZoom => "shift-escape", + Self::CommandPalette => "cmd-shift-p", + Self::OpenSettings => "cmd-,", + }; + + #[cfg(not(target_os = "macos"))] + return match self { + Self::CloseItem => "ctrl-w", + Self::NewTerminal => "ctrl-~", + Self::FocusLeft => "ctrl-k ctrl-left", + Self::FocusRight => "ctrl-k ctrl-right", + Self::FocusUp => "ctrl-k ctrl-up", + Self::FocusDown => "ctrl-k ctrl-down", + Self::ToggleZoom => "shift-escape", + Self::CommandPalette => "ctrl-shift-p", + Self::OpenSettings => "ctrl-,", + }; + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct Content { + #[serde(default)] + bindings: BTreeMap, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Clone)] +pub struct KeymapStore { + file: Option, + content: Content, + problem: Option, +} + +impl KeymapStore { + pub fn load(file: impl Into) -> Self { + let file = file.into(); + match fs::read_to_string(&file) { + Ok(text) => match toml::from_str(&text) { + Ok(content) => Self { file: Some(file), content, problem: None }, + Err(error) => Self { + file: Some(file.clone()), + content: Content::default(), + problem: Some(format!( + "Chartr could not read {}, so default shortcuts are active: {error}", + file.display() + )), + }, + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + Self { file: Some(file), content: Content::default(), problem: None } + } + Err(error) => Self { + file: Some(file.clone()), + content: Content::default(), + problem: Some(format!("Chartr could not read {}: {error}", file.display())), + }, + } + } + + pub fn bare() -> Self { + Self { file: None, content: Content::default(), problem: None } + } + + pub fn key(&self, action: KeymapAction) -> &str { + self.content + .bindings + .get(action.id()) + .map(String::as_str) + .unwrap_or_else(|| action.default_key()) + } + + pub fn problem(&self) -> Option<&str> { + self.problem.as_deref() + } + + pub fn set(&mut self, action: KeymapAction, key: String) -> Result<(), Error> { + validate_chord(&key)?; + if let Some(conflict) = KeymapAction::ALL + .into_iter() + .find(|candidate| *candidate != action && self.key(*candidate) == key) + { + return Err(Error::Conflict { key, action: conflict }); + } + if key == action.default_key() { + self.content.bindings.remove(action.id()); + } else { + self.content.bindings.insert(action.id().to_owned(), key); + } + self.save().map_err(Error::Write) + } + + fn save(&self) -> io::Result<()> { + let Some(file) = &self.file else { + return Ok(()); + }; + if let Some(problem) = &self.problem { + return Err(io::Error::other(format!( + "{problem}; Chartr will not overwrite a keymap it cannot read" + ))); + } + let parent = file.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged.write_all( + toml::to_string_pretty(&self.content).map_err(io::Error::other)?.as_bytes(), + )?; + staged.flush()?; + staged.as_file().sync_all()?; + staged.persist(file).map_err(|error| error.error)?; + Ok(()) + } +} + +fn validate_chord(chord: &str) -> Result<(), Error> { + if chord.trim().is_empty() { + return Err(Error::Invalid(chord.to_owned())); + } + for stroke in chord.split_whitespace() { + gpui::Keystroke::parse(stroke).map_err(|_| Error::Invalid(chord.to_owned()))?; + } + Ok(()) +} + +#[derive(Debug)] +pub enum Error { + Invalid(String), + Conflict { key: String, action: KeymapAction }, + Write(io::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Invalid(key) => write!(formatter, "{key:?} is not a valid shortcut"), + Self::Conflict { key, action } => { + write!(formatter, "{key} is already assigned to {}", action.title()) + } + Self::Write(error) => write!(formatter, "saving the keymap: {error}"), + } + } +} + +impl std::error::Error for Error {} + +pub fn keymap_file() -> Result { + Ok(crate::spaces::config_root()?.join(KEYMAP_FILE)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_overrides_round_trip_and_defaults_remain() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join(KEYMAP_FILE); + let mut store = KeymapStore::load(&file); + store.set(KeymapAction::CloseItem, "ctrl-alt-w".to_owned()).unwrap(); + let loaded = KeymapStore::load(file); + assert_eq!(loaded.key(KeymapAction::CloseItem), "ctrl-alt-w"); + assert_eq!(loaded.key(KeymapAction::ToggleZoom), KeymapAction::ToggleZoom.default_key()); + } + + #[test] + fn conflicts_in_the_chartr_context_are_refused() { + let mut store = KeymapStore::bare(); + let key = store.key(KeymapAction::CloseItem).to_owned(); + let error = store.set(KeymapAction::NewTerminal, key).unwrap_err(); + assert!(matches!(error, Error::Conflict { action: KeymapAction::CloseItem, .. })); + } +} diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 5505bbea..67a49d3e 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -1,31 +1,58 @@ -//! zeddy — a simple agent multiplexer. +//! Chartr — a multi-space agent multiplexer. use std::path::PathBuf; -use gpui::{App, AppContext as _, Bounds, Focusable as _, WindowBounds, WindowOptions, px, size}; +use gpui::{ + App, AppContext as _, Bounds, Focusable as _, WindowBounds, WindowOptions, point, px, size, +}; use gpui_platform::application; +mod actions; mod app; -mod assets; mod chrome; mod fonts; +mod item; +mod keymap; mod keys; mod mode; mod palette; +mod persistence; mod session; +mod settings; +mod space; +mod spaces; mod terminal; +mod web_plugin; +mod workspace; fn main() { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - application().with_assets(assets::Assets).run(move |cx: &mut App| { + application().with_assets(zed_assets::Assets).run(move |cx: &mut App| { + let settings = settings::settings_file() + .map(settings::SettingsStore::load) + .unwrap_or_else(|_| settings::SettingsStore::bare()); + let keymap = keymap::keymap_file() + .map(keymap::KeymapStore::load) + .unwrap_or_else(|_| keymap::KeymapStore::bare()); // `JustBase` loads no theme JSON, which means no asset source and no // bundled themes. zeddy has no theme picker, so the built-in dark theme // is the whole theming story until it does. - theme::init(theme::LoadThemes::JustBase, cx); + theme::init(theme::LoadThemes::All(Box::new(zed_assets::Assets)), cx); + settings::init_themes(settings.resolved(), cx); + if let Err(error) = zed_assets::Assets.load_fonts(cx) { + eprintln!("Chartr could not load its bundled fonts: {error}"); + } + if let Err(error) = fonts::load_bundled(cx) { + eprintln!("Chartr could not load IBM Plex Mono: {error}"); + } // Zed's components read their font through this, and zeddy has no // settings file for the `theme_settings` crate to read one from. - theme::set_theme_settings_provider(Box::new(fonts::Fonts::default()), cx); + theme::set_theme_settings_provider( + Box::new(fonts::Fonts::from_settings(settings.resolved())), + cx, + ); + actions::init(&keymap, cx); // A build whose platform layer cannot rasterise glyphs paints every // quad and icon correctly and shows not one character. Saying so is @@ -39,28 +66,57 @@ fn main() { return; } - let bounds = Bounds::centered(None, size(px(1100.), px(720.)), cx); + let bounds = persistence::state_file() + .ok() + .and_then(|path| persistence::StateStore::open(path).ok()) + .and_then(|store| store.load().ok()) + .and_then(|snapshot| snapshot.window.bounds) + .filter(|bounds| { + bounds.x.is_finite() + && bounds.y.is_finite() + && bounds.width.is_finite() + && bounds.height.is_finite() + && bounds.width >= 640. + && bounds.height >= 420. + }) + .map(|bounds| { + Bounds::new( + point(px(bounds.x), px(bounds.y)), + size(px(bounds.width), px(bounds.height)), + ) + }) + .unwrap_or_else(|| Bounds::centered(None, size(px(1100.), px(720.)), cx)); let window = cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { - title: Some("zeddy".into()), + title: Some("Chartr".into()), ..Default::default() }), ..Default::default() }, |window, cx| { - let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), cx)); + let settings = settings.clone(); + let keymap = keymap.clone(); + let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), settings, keymap, cx)); window.focus(&view.read(cx).focus_handle(cx), cx); view }, ); - if let Err(err) = window { - eprintln!("zeddy could not open a window: {err}"); - cx.quit(); - return; - } + let window = match window { + Ok(window) => window, + Err(err) => { + eprintln!("zeddy could not open a window: {err}"); + cx.quit(); + return; + } + }; + cx.on_app_quit(move |cx| { + let _ = window.update(cx, |zeddy, _, cx| zeddy.apply_exit_policy(cx)); + async {} + }) + .detach(); cx.activate(true); }); } diff --git a/crates/zeddy/src/mode.rs b/crates/zeddy/src/mode.rs index f369f247..c6e9ca86 100644 --- a/crates/zeddy/src/mode.rs +++ b/crates/zeddy/src/mode.rs @@ -5,8 +5,11 @@ //! layouts: nothing below the chrome knows which one is showing, and toggling //! never touches a session. +use serde::{Deserialize, Serialize}; + /// Where the session list is drawn. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum Mode { /// A vertical list down the left. Wide enough for a directory, an agent /// name, and a status — the mode for many long-lived sessions. diff --git a/crates/zeddy/src/persistence.rs b/crates/zeddy/src/persistence.rs new file mode 100644 index 00000000..0807cedc --- /dev/null +++ b/crates/zeddy/src/persistence.rs @@ -0,0 +1,294 @@ +//! Versioned SQLite persistence for application-owned cockpit state. +//! +//! User-editable settings, keymaps, and themes remain files. This database +//! stores only the state Chartr owns: spaces, pane trees, restorable item +//! identities, chrome state, and window geometry. + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; + +use anyhow::{Context as _, Result}; +use rusqlite::{Connection, OptionalExtension as _, params}; +use serde::{Deserialize, Serialize}; + +use crate::{mode::Mode, workspace::Workspace}; + +pub const STATE_FILE: &str = "state.sqlite"; +const SCHEMA_VERSION: i64 = 1; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SidebarScope { + #[default] + AllSpaces, + ActiveSpace, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WindowState { + pub chrome: Mode, + pub sidebar_scope: SidebarScope, + pub sidebar_width: f32, + pub active_space: Option, + pub bounds: Option, +} + +impl Default for WindowState { + fn default() -> Self { + Self { + chrome: Mode::Sidebar, + sidebar_scope: SidebarScope::AllSpaces, + sidebar_width: 280., + active_space: Some("ad-hoc".to_owned()), + bounds: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowBounds { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpaceKind { + AdHoc, + Folder, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PersistedSpace { + pub key: String, + pub name: String, + pub path: Option, + pub kind: SpaceKind, + pub layout: Workspace, + pub items: Vec, + pub expanded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PersistedItem { + Terminal { + item_id: u64, + backend_id: String, + }, + Plugin { + item_id: u64, + plugin: String, + pane: String, + state: Option, + bound_session: Option, + }, +} + +impl PersistedItem { + pub fn item_id(&self) -> u64 { + match self { + Self::Terminal { item_id, .. } | Self::Plugin { item_id, .. } => *item_id, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + pub window: WindowState, + pub spaces: Vec, +} + +pub struct StateStore { + connection: Connection, +} + +impl StateStore { + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating state directory {}", parent.display()))?; + } + let connection = Connection::open(path) + .with_context(|| format!("opening state database {}", path.display()))?; + let mut store = Self { connection }; + store.migrate()?; + Ok(store) + } + + #[cfg(test)] + fn memory() -> Result { + let connection = Connection::open_in_memory()?; + let mut store = Self { connection }; + store.migrate()?; + Ok(store) + } + + fn migrate(&mut self) -> Result<()> { + let version: i64 = + self.connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if version > SCHEMA_VERSION { + anyhow::bail!( + "state database schema {version} is newer than this Chartr supports ({SCHEMA_VERSION})" + ); + } + if version == 0 { + let transaction = self.connection.transaction()?; + transaction.execute_batch( + "CREATE TABLE app_state ( + key TEXT PRIMARY KEY NOT NULL, + value_json TEXT NOT NULL + ); + CREATE TABLE spaces ( + space_key TEXT PRIMARY KEY NOT NULL, + ordinal INTEGER NOT NULL, + value_json TEXT NOT NULL + );", + )?; + transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; + transaction.commit()?; + } + Ok(()) + } + + pub fn load(&self) -> Result { + let window = self + .connection + .query_row("SELECT value_json FROM app_state WHERE key = 'window'", [], |row| { + row.get::<_, String>(0) + }) + .optional()? + .map(|json| serde_json::from_str(&json)) + .transpose() + .context("decoding saved window state")? + .unwrap_or_default(); + let mut statement = + self.connection.prepare("SELECT value_json FROM spaces ORDER BY ordinal")?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + let mut spaces = Vec::new(); + for row in rows { + spaces.push(serde_json::from_str(&row?).context("decoding a saved space")?); + } + Ok(Snapshot { window, spaces }) + } + + pub fn save(&mut self, snapshot: &Snapshot) -> Result<()> { + let transaction = self.connection.transaction()?; + transaction.execute( + "INSERT INTO app_state (key, value_json) VALUES ('window', ?1) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json", + [serde_json::to_string(&snapshot.window)?], + )?; + transaction.execute("DELETE FROM spaces", [])?; + { + let mut insert = transaction.prepare( + "INSERT INTO spaces (space_key, ordinal, value_json) VALUES (?1, ?2, ?3)", + )?; + for (ordinal, space) in snapshot.spaces.iter().enumerate() { + insert.execute(params![ + space.key, + ordinal as i64, + serde_json::to_string(space)? + ])?; + } + } + transaction.commit()?; + Ok(()) + } + + #[cfg(test)] + fn schema_version(&self) -> Result { + Ok(self.connection.pragma_query_value(None, "user_version", |row| row.get(0))?) + } +} + +pub fn state_file() -> Result { + state_root_from(std::env::var_os("XDG_STATE_HOME"), std::env::home_dir()) + .map(|root| root.join(STATE_FILE)) +} + +fn state_root_from(xdg: Option, home: Option) -> Result { + if let Some(xdg) = xdg.filter(|value| Path::new(value).is_absolute()) { + return Ok(PathBuf::from(xdg).join("chartr-zeddy")); + } + home.filter(|path| !path.as_os_str().is_empty()) + .map(|home| home.join(".local/state/chartr-zeddy")) + .context("no state directory is available") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn space(key: &str) -> PersistedSpace { + let mut layout = Workspace::new(); + let root = layout.active_pane(); + let right = layout.split_pane(root, crate::workspace::SplitDirection::Right).unwrap(); + let item = layout.alloc_item(); + layout.add_item(item, Some(right), None).unwrap(); + PersistedSpace { + key: key.to_owned(), + name: "Project".to_owned(), + path: Some(PathBuf::from("/tmp/project")), + kind: SpaceKind::Folder, + layout, + items: vec![PersistedItem::Terminal { + item_id: item.get(), + backend_id: "pane-1".to_owned(), + }], + expanded: true, + } + } + + #[test] + fn a_new_database_runs_the_versioned_schema() { + let store = StateStore::memory().unwrap(); + assert_eq!(store.schema_version().unwrap(), SCHEMA_VERSION); + } + + #[test] + fn complete_snapshots_round_trip_in_space_order() { + let mut store = StateStore::memory().unwrap(); + let snapshot = Snapshot { + window: WindowState { + chrome: Mode::Tabs, + sidebar_width: 312., + active_space: Some("two".to_owned()), + ..WindowState::default() + }, + spaces: vec![space("one"), space("two")], + }; + store.save(&snapshot).unwrap(); + let restored = store.load().unwrap(); + assert_eq!(restored, snapshot); + restored.spaces[0].layout.validate().unwrap(); + } + + #[test] + fn saving_is_a_transaction_that_replaces_removed_spaces() { + let mut store = StateStore::memory().unwrap(); + store + .save(&Snapshot { spaces: vec![space("one"), space("two")], ..Snapshot::default() }) + .unwrap(); + store.save(&Snapshot { spaces: vec![space("two")], ..Snapshot::default() }).unwrap(); + assert_eq!(store.load().unwrap().spaces[0].key, "two"); + assert_eq!(store.load().unwrap().spaces.len(), 1); + } + + #[test] + fn state_paths_are_isolated_from_old_chartr() { + assert_eq!( + state_root_from(Some(OsString::from("/state")), None).unwrap(), + PathBuf::from("/state/chartr-zeddy") + ); + assert_eq!( + state_root_from(None, Some(PathBuf::from("/home/op"))).unwrap(), + PathBuf::from("/home/op/.local/state/chartr-zeddy") + ); + } +} diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 84b91b03..866c5790 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -44,7 +44,7 @@ pub struct Session { pub info: control::Session, terminal: Arc>, ended: Arc>>, - input: Input, + input: Arc>, size: Size, } @@ -101,13 +101,17 @@ impl Session { }) .expect("spawn a session reader thread"); - Ok(Self { info, terminal, ended, input, size }) + Ok(Self { info, terminal, ended, input: Arc::new(Mutex::new(input)), size }) } pub fn id(&self) -> &PaneId { &self.info.id } + pub fn size(&self) -> Size { + self.size + } + /// The screen as it stands. Cheap enough to call once per paint. pub fn screen(&self) -> Screen { self.terminal.lock().expect("terminal mutex").screen() @@ -131,7 +135,7 @@ impl Session { /// Send typed bytes to the session. pub fn send(&mut self, bytes: &[u8]) -> zeddy_herdr::Result<()> { - self.input.send(bytes) + self.input.lock().expect("session input mutex").send(bytes) } /// Tell the session how many cells it now has. @@ -143,12 +147,28 @@ impl Session { return Ok(()); } self.size = size; - self.input.resize(geometry(size)) + self.input.lock().expect("session input mutex").resize(geometry(size)) } /// Detach cleanly, leaving the session running for the next launch. pub fn release(&mut self) { - let _ = self.input.release(); + let _ = self.input.lock().expect("session input mutex").release(); + } + + pub fn access(&self) -> SessionAccess { + SessionAccess { info: self.info.clone(), input: self.input.clone() } + } +} + +#[derive(Clone)] +pub struct SessionAccess { + pub info: control::Session, + input: Arc>, +} + +impl SessionAccess { + pub fn send(&self, bytes: &[u8]) -> zeddy_herdr::Result<()> { + self.input.lock().expect("session input mutex").send(bytes) } } diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs new file mode 100644 index 00000000..e0153128 --- /dev/null +++ b/crates/zeddy/src/settings.rs @@ -0,0 +1,513 @@ +//! Chartr's user-global settings store. +//! +//! Like Zed, the serialized content is sparse and optional while runtime +//! consumers receive a complete resolved value. Like Chartr-rs, a malformed +//! operator-owned file is reported and never overwritten, and successful +//! updates replace the file atomically. + +use std::{ + collections::BTreeMap, + fs, + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use theme::{Appearance, GlobalTheme, SystemAppearance, Theme, ThemeRegistry}; + +pub const SETTINGS_FILE: &str = "settings.toml"; +pub const CHARTR_DARK: &str = "Chartr Dark"; +pub const CHARTR_LIGHT: &str = "Chartr Light"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SettingsPage { + #[default] + General, + Appearance, + Terminal, + Hotkeys, + Plugins, +} + +impl SettingsPage { + pub const ALL: [Self; 5] = + [Self::General, Self::Appearance, Self::Terminal, Self::Hotkeys, Self::Plugins]; + + pub fn title(self) -> &'static str { + match self { + Self::General => "General", + Self::Appearance => "Appearance", + Self::Terminal => "Terminal", + Self::Hotkeys => "Hotkeys", + Self::Plugins => "Plugins", + } + } + + pub fn slug(self) -> &'static str { + match self { + Self::General => "general", + Self::Appearance => "appearance", + Self::Terminal => "terminal", + Self::Hotkeys => "hotkeys", + Self::Plugins => "plugins", + } + } +} + +const HEADER: &str = "\ +# Chartr-zeddy user settings. Omitted fields use Chartr's defaults. +# This namespace is intentionally isolated from previous Chartr installations. +"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeMode { + #[default] + Fixed, + System, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedSettings { + pub terminate_sessions_on_exit: bool, + pub theme_mode: ThemeMode, + pub fixed_theme: String, + pub light_theme: String, + pub dark_theme: String, + pub ui_font_family: String, + pub ui_font_size: f32, + pub terminal_font_family: String, + pub terminal_font_size: f32, + pub ad_hoc_directory: Option, + pub plugins: BTreeMap, +} + +impl Default for ResolvedSettings { + fn default() -> Self { + Self { + terminate_sessions_on_exit: false, + theme_mode: ThemeMode::Fixed, + fixed_theme: CHARTR_DARK.to_owned(), + light_theme: CHARTR_LIGHT.to_owned(), + dark_theme: CHARTR_DARK.to_owned(), + ui_font_family: "IBM Plex Sans".to_owned(), + ui_font_size: 14., + terminal_font_family: "IBM Plex Mono".to_owned(), + terminal_font_size: 13., + ad_hoc_directory: None, + plugins: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct SettingsContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub general: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub appearance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub plugins: BTreeMap, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct PluginSettingsContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unsafe_filesystem: Option, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginSettings { + pub enabled: bool, + pub unsafe_filesystem: bool, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { enabled: true, unsafe_filesystem: false } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct GeneralContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub terminate_sessions_on_exit: Option, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct AppearanceContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub theme_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fixed_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub light_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dark_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ui_font_family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ui_font_size: Option, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct TerminalContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub font_family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ad_hoc_directory: Option, + #[serde(flatten)] + extra: toml::Table, +} + +impl SettingsContent { + pub fn resolve(&self) -> ResolvedSettings { + let defaults = ResolvedSettings::default(); + let general = self.general.as_ref(); + let appearance = self.appearance.as_ref(); + let terminal = self.terminal.as_ref(); + ResolvedSettings { + terminate_sessions_on_exit: general + .and_then(|content| content.terminate_sessions_on_exit) + .unwrap_or(defaults.terminate_sessions_on_exit), + theme_mode: appearance + .and_then(|content| content.theme_mode) + .unwrap_or(defaults.theme_mode), + fixed_theme: appearance + .and_then(|content| content.fixed_theme.clone()) + .unwrap_or(defaults.fixed_theme), + light_theme: appearance + .and_then(|content| content.light_theme.clone()) + .unwrap_or(defaults.light_theme), + dark_theme: appearance + .and_then(|content| content.dark_theme.clone()) + .unwrap_or(defaults.dark_theme), + ui_font_family: appearance + .and_then(|content| content.ui_font_family.clone()) + .unwrap_or(defaults.ui_font_family), + ui_font_size: appearance + .and_then(|content| content.ui_font_size) + .filter(|size| size.is_finite() && *size >= 8. && *size <= 32.) + .unwrap_or(defaults.ui_font_size), + terminal_font_family: terminal + .and_then(|content| content.font_family.clone()) + .unwrap_or(defaults.terminal_font_family), + terminal_font_size: terminal + .and_then(|content| content.font_size) + .filter(|size| size.is_finite() && *size >= 8. && *size <= 72.) + .unwrap_or(defaults.terminal_font_size), + ad_hoc_directory: terminal.and_then(|content| content.ad_hoc_directory.clone()), + plugins: self + .plugins + .iter() + .map(|(id, content)| { + ( + id.clone(), + PluginSettings { + enabled: content.enabled.unwrap_or(true), + unsafe_filesystem: content.unsafe_filesystem.unwrap_or(false), + }, + ) + }) + .collect(), + } + } +} + +impl ResolvedSettings { + pub fn plugin(&self, id: &str) -> PluginSettings { + self.plugins.get(id).copied().unwrap_or_default() + } +} + +#[derive(Debug, Clone)] +pub struct SettingsStore { + file: Option, + content: SettingsContent, + resolved: ResolvedSettings, + unreadable: Option, +} + +impl SettingsStore { + pub fn load(file: impl Into) -> Self { + let file = file.into(); + let mut content = SettingsContent::default(); + let mut unreadable = None; + match fs::read_to_string(&file) { + Ok(text) => match toml::from_str(&text) { + Ok(read) => content = read, + Err(error) => { + unreadable = Some(format!( + "Chartr could not read {}, so defaults are active: {error}", + file.display() + )) + } + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + unreadable = Some(format!("Chartr could not read {}: {error}", file.display())) + } + } + let resolved = content.resolve(); + Self { file: Some(file), content, resolved, unreadable } + } + + pub fn bare() -> Self { + let content = SettingsContent::default(); + let resolved = content.resolve(); + Self { file: None, content, resolved, unreadable: None } + } + + pub fn resolved(&self) -> &ResolvedSettings { + &self.resolved + } + + pub fn content(&self) -> &SettingsContent { + &self.content + } + + pub fn unreadable(&self) -> Option<&str> { + self.unreadable.as_deref() + } + + pub fn update( + &mut self, + mutate: impl FnOnce(&mut SettingsContent), + ) -> Result<&ResolvedSettings, io::Error> { + let mut candidate = self.content.clone(); + mutate(&mut candidate); + self.save(&candidate)?; + self.resolved = candidate.resolve(); + self.content = candidate; + Ok(&self.resolved) + } + + fn save(&self, content: &SettingsContent) -> io::Result<()> { + let Some(file) = &self.file else { + return Ok(()); + }; + if let Some(error) = &self.unreadable { + return Err(io::Error::other(format!( + "{error}; Chartr will not overwrite settings it cannot read" + ))); + } + let parent = file.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let encoded = toml::to_string_pretty(content).map_err(io::Error::other)?; + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged.write_all(format!("{HEADER}\n{encoded}").as_bytes())?; + staged.flush()?; + staged.as_file().sync_all()?; + staged.persist(file).map_err(|error| error.error)?; + Ok(()) + } +} + +impl Default for SettingsStore { + fn default() -> Self { + Self::bare() + } +} + +pub fn settings_file() -> Result { + Ok(crate::spaces::config_root()?.join(SETTINGS_FILE)) +} + +/// Register Chartr's named semantic theme pair, then select the resolved +/// fixed/system variant. Both are ordinary Zed `Theme` values, so every Zed +/// component consumes the same tokens as Chartr's product views. +pub fn init_themes(settings: &ResolvedSettings, cx: &mut gpui::App) { + let registry = ThemeRegistry::global(cx); + if let Ok(source) = registry.get("One Dark") { + let mut dark = (*source).clone(); + dark.id = "chartr_dark".to_owned(); + dark.name = CHARTR_DARK.into(); + let light = chartr_light(&dark); + registry.insert_themes([dark, light]); + } + apply_theme(settings, cx); +} + +fn chartr_light(dark: &Theme) -> Theme { + let mut light = dark.clone(); + light.id = "chartr_light".to_owned(); + light.name = CHARTR_LIGHT.into(); + light.appearance = Appearance::Light; + let colors = &mut light.styles.colors; + let canvas = gpui::rgb(0xf7f8fa).into(); + let surface = gpui::rgb(0xffffff).into(); + let raised = gpui::rgb(0xf1f3f5).into(); + let hover = gpui::rgb(0xe8ebef).into(); + let selected = gpui::rgb(0xdce6f5).into(); + let border = gpui::rgb(0xd4d8de).into(); + let text = gpui::rgb(0x24272d).into(); + let muted = gpui::rgb(0x66707d).into(); + + colors.background = canvas; + colors.surface_background = surface; + colors.elevated_surface_background = surface; + colors.element_background = raised; + colors.element_hover = hover; + colors.element_active = selected; + colors.element_selected = selected; + colors.ghost_element_hover = hover; + colors.ghost_element_active = selected; + colors.ghost_element_selected = selected; + colors.border = border; + colors.border_variant = border; + colors.text = text; + colors.text_muted = muted; + colors.text_placeholder = muted; + colors.text_disabled = muted; + colors.icon = text; + colors.icon_muted = muted; + colors.icon_placeholder = muted; + colors.icon_disabled = muted; + colors.title_bar_background = canvas; + colors.title_bar_inactive_background = raised; + colors.toolbar_background = surface; + colors.tab_bar_background = raised; + colors.tab_inactive_background = raised; + colors.tab_active_background = surface; + colors.panel_background = surface; + colors.editor_background = surface; + colors.editor_foreground = text; + colors.editor_gutter_background = surface; + colors.editor_subheader_background = raised; + light +} + +pub fn apply_theme(settings: &ResolvedSettings, cx: &mut gpui::App) { + let name = match settings.theme_mode { + ThemeMode::Fixed => settings.fixed_theme.as_str(), + ThemeMode::System => match *SystemAppearance::global(cx) { + Appearance::Light => settings.light_theme.as_str(), + Appearance::Dark => settings.dark_theme.as_str(), + }, + }; + if let Ok(theme) = ThemeRegistry::global(cx).get(name) { + GlobalTheme::update_theme(cx, theme); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_file_resolves_to_fixed_chartr_dark() { + let scratch = tempfile::tempdir().unwrap(); + let store = SettingsStore::load(scratch.path().join(SETTINGS_FILE)); + assert_eq!(store.resolved().theme_mode, ThemeMode::Fixed); + assert_eq!(store.resolved().fixed_theme, CHARTR_DARK); + assert!(!scratch.path().join(SETTINGS_FILE).exists()); + } + + #[test] + fn sparse_settings_merge_with_complete_defaults() { + let content: SettingsContent = toml::from_str( + "[appearance]\nui_font_size = 16\n[terminal]\nfont_family = 'Monaspace Neon'\n", + ) + .unwrap(); + let resolved = content.resolve(); + assert_eq!(resolved.ui_font_size, 16.); + assert_eq!(resolved.ui_font_family, "IBM Plex Sans"); + assert_eq!(resolved.terminal_font_family, "Monaspace Neon"); + assert_eq!(resolved.fixed_theme, CHARTR_DARK); + } + + #[test] + fn an_update_is_atomic_and_survives_relaunch() { + let scratch = tempfile::tempdir().unwrap(); + let file = scratch.path().join(SETTINGS_FILE); + let mut store = SettingsStore::load(&file); + store + .update(|content| { + content.terminal.get_or_insert_default().font_size = Some(17.); + }) + .unwrap(); + let relaunched = SettingsStore::load(&file); + assert_eq!(relaunched.resolved().terminal_font_size, 17.); + assert!(fs::read_to_string(file).unwrap().starts_with("# Chartr-zeddy")); + } + + #[test] + fn malformed_operator_settings_are_never_overwritten() { + let scratch = tempfile::tempdir().unwrap(); + let file = scratch.path().join(SETTINGS_FILE); + fs::write(&file, "[[[ not toml\n").unwrap(); + let mut store = SettingsStore::load(&file); + assert!(store.unreadable().is_some()); + assert!(store.update(|_| {}).is_err()); + assert_eq!(fs::read_to_string(file).unwrap(), "[[[ not toml\n"); + } + + #[test] + fn unknown_keys_survive_known_updates() { + let scratch = tempfile::tempdir().unwrap(); + let file = scratch.path().join(SETTINGS_FILE); + fs::write(&file, "future_root = 'kept'\n[appearance]\nfuture_color = 'also kept'\n") + .unwrap(); + let mut store = SettingsStore::load(&file); + store + .update(|content| { + content.appearance.get_or_insert_default().ui_font_size = Some(15.); + }) + .unwrap(); + let written = fs::read_to_string(file).unwrap(); + assert!(written.contains("future_root")); + assert!(written.contains("future_color")); + } + + #[test] + fn invalid_font_sizes_fall_back_without_destroying_user_content() { + let content: SettingsContent = + toml::from_str("[appearance]\nui_font_size = 2\n[terminal]\nfont_size = 1000\n") + .unwrap(); + assert_eq!(content.resolve().ui_font_size, 14.); + assert_eq!(content.resolve().terminal_font_size, 13.); + assert_eq!(content.appearance.unwrap().ui_font_size, Some(2.)); + } + + #[test] + fn plugin_grants_are_per_plugin_and_survive_relaunch() { + let scratch = tempfile::tempdir().unwrap(); + let file = scratch.path().join(SETTINGS_FILE); + let mut store = SettingsStore::load(&file); + store + .update(|content| { + content.plugins.insert( + "com.example.notes".to_owned(), + PluginSettingsContent { + enabled: Some(false), + unsafe_filesystem: Some(true), + ..PluginSettingsContent::default() + }, + ); + }) + .unwrap(); + let relaunched = SettingsStore::load(file); + let notes = relaunched.resolved().plugin("com.example.notes"); + assert!(!notes.enabled); + assert!(notes.unsafe_filesystem); + assert_eq!( + relaunched.resolved().plugin("com.example.other"), + PluginSettings::default(), + "there is no global unsafe grant" + ); + } +} diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs new file mode 100644 index 00000000..0e5e98ef --- /dev/null +++ b/crates/zeddy/src/space.rs @@ -0,0 +1,841 @@ +//! One space: a folder, its backend workspace, and its open items. +//! +//! A space is independently stateful in the same way a Zed `Workspace` held by +//! `MultiWorkspace` is: it owns its sessions and active item, while the parent +//! owns the ordered collection and decides which space the window presents. + +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +use futures::{StreamExt as _, channel::mpsc}; +use gpui::{Context, Task}; +use zeddy_herdr::{PaneId, WorkspaceId, control::Client}; + +use crate::{ + chrome::{Action, Entry, PaneEntries}, + item::{Item, PluginItem, SessionItem}, + persistence::{PersistedItem, PersistedSpace, SpaceKind as PersistedSpaceKind}, + session::Session, + spaces, + workspace::{ItemId, SplitDirection, Workspace}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + AdHoc, + Registered, +} + +pub struct Space { + name: String, + path: PathBuf, + kind: Kind, + client: Client, + workspace: Option, + layout: Workspace, + items: HashMap, + sessions: HashMap, + starting: bool, + closing: HashSet, + reattaching: HashSet, + restoring_sessions: HashMap, + restoring_plugins: Vec, + drag_target: Option<(crate::workspace::PaneId, Option)>, + problem: Option, + wakeup_tx: mpsc::UnboundedSender<()>, + _wakeups: Task<()>, +} + +impl Space { + pub fn new( + name: String, + path: PathBuf, + kind: Kind, + client: Client, + cx: &mut Context, + ) -> Self { + let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); + Self { + name, + path, + kind, + client, + workspace: None, + layout: Workspace::new(), + items: HashMap::new(), + sessions: HashMap::new(), + starting: false, + closing: HashSet::new(), + reattaching: HashSet::new(), + restoring_sessions: HashMap::new(), + restoring_plugins: Vec::new(), + drag_target: None, + problem: None, + wakeup_tx, + _wakeups: Self::watch(wakeup_rx, cx), + } + } + + fn watch(mut wakeups: mpsc::UnboundedReceiver<()>, cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + while wakeups.next().await.is_some() { + while wakeups.try_recv().is_ok() {} + if this.update(cx, |_, cx| cx.notify()).is_err() { + return; + } + } + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn set_name(&mut self, name: String) { + self.name = name; + } + + pub fn path(&self) -> &PathBuf { + &self.path + } + + pub fn set_path(&mut self, path: PathBuf) { + self.path = path; + self.workspace = None; + } + + pub fn kind(&self) -> Kind { + self.kind + } + + pub fn available(&self) -> bool { + self.kind == Kind::AdHoc || self.path.is_dir() + } + + pub fn problem(&self) -> Option<&str> { + self.problem.as_deref() + } + + pub fn layout(&self) -> &Workspace { + &self.layout + } + + pub fn active(&self) -> Option { + self.layout.pane(self.layout.active_pane()).and_then(|pane| pane.active()) + } + + pub fn item(&self, id: ItemId) -> Option<&Item> { + self.items.get(&id) + } + + pub fn reattaching(&self, id: ItemId) -> bool { + self.reattaching.contains(&id) + } + + pub fn session_access(&self, backend: &PaneId) -> Option { + let item = self.sessions.get(backend)?; + self.items.get(item)?.as_session().map(|item| item.session.access()) + } + + pub fn active_session_id(&self) -> Option { + self.active() + .and_then(|item| self.items.get(&item)) + .and_then(Item::as_session) + .map(|item| item.session.id().clone()) + } + + pub fn reattach(&mut self, id: ItemId, cx: &mut Context) { + if !self.reattaching.insert(id) { + return; + } + let Some(session) = self.items.get(&id).and_then(Item::as_session) else { + self.reattaching.remove(&id); + return; + }; + let backend_id = session.session.id().clone(); + let size = session.session.size(); + let client = self.client.clone(); + let wakeups = self.wakeup_tx.clone(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let result = executor + .spawn(async move { + let info = client + .sessions(None)? + .into_iter() + .find(|info| info.id == backend_id) + .ok_or_else(|| { + zeddy_herdr::Error::Protocol(format!( + "Herdr no longer reports session {}", + backend_id.0 + )) + })?; + Session::attach(&client, info, size, wakeups) + }) + .await; + let _ = this.update(cx, |this, cx| { + this.reattaching.remove(&id); + match result { + Ok(session) => { + if let Some(item) = this.items.get_mut(&id).and_then(Item::as_session_mut) { + item.session = session; + this.problem = None; + } + } + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + }); + }) + .detach(); + } + + pub fn key(&self) -> String { + match self.kind { + Kind::AdHoc => "ad-hoc".to_owned(), + Kind::Registered => format!("folder:{}", self.path.to_string_lossy()), + } + } + + pub fn restore_saved(&mut self, saved: &PersistedSpace) { + self.layout = saved.layout.clone(); + self.restoring_sessions.clear(); + self.restoring_plugins.clear(); + let mut retained = HashSet::new(); + for item in &saved.items { + let id = self.layout.item_ids().find(|candidate| candidate.get() == item.item_id()); + if let Some(id) = id { + retained.insert(id); + match item { + PersistedItem::Terminal { backend_id, .. } => { + self.restoring_sessions.insert(backend_id.clone(), id); + } + PersistedItem::Plugin { .. } => self.restoring_plugins.push(item.clone()), + } + } + } + let invalid: Vec<_> = + self.layout.item_ids().filter(|item| !retained.contains(item)).collect(); + for item in invalid { + let _ = self.layout.remove_item(item); + } + } + + pub fn persisted(&self) -> PersistedSpace { + let key = self.key(); + let mut items: Vec<_> = self + .items + .iter() + .filter_map(|(id, item)| match item { + Item::Session(item) => Some(PersistedItem::Terminal { + item_id: id.get(), + backend_id: item.session.id().0.clone(), + }), + Item::Plugin(item) if item.restorable => Some(PersistedItem::Plugin { + item_id: id.get(), + plugin: item.contribution.plugin.clone(), + pane: item.contribution.key.clone(), + state: None, + bound_session: item.bound_session.as_ref().map(|id| id.0.clone()), + }), + Item::Plugin(_) => None, + }) + .collect(); + items.extend(self.restoring_sessions.iter().map(|(backend_id, item)| { + PersistedItem::Terminal { item_id: item.get(), backend_id: backend_id.clone() } + })); + items.extend(self.restoring_plugins.iter().cloned()); + PersistedSpace { + key, + name: self.name.clone(), + path: (self.kind == Kind::Registered).then(|| self.path.clone()), + kind: match self.kind { + Kind::AdHoc => PersistedSpaceKind::AdHoc, + Kind::Registered => PersistedSpaceKind::Folder, + }, + layout: self.layout.clone(), + items, + expanded: true, + } + } + + pub fn activate_pane_in_direction(&mut self, direction: SplitDirection) { + self.layout.activate_pane_in_direction(direction); + } + + pub fn activate_pane(&mut self, pane: crate::workspace::PaneId) { + if let Err(error) = self.layout.activate_pane(pane) { + self.problem = Some(error.to_string()); + } + } + + pub fn drag_target(&self) -> Option<(crate::workspace::PaneId, Option)> { + self.drag_target + } + + pub fn set_drag_target( + &mut self, + pane: crate::workspace::PaneId, + direction: Option, + ) { + self.drag_target = Some((pane, direction)); + } + + pub fn resize_divider(&mut self, axis_path: &[usize], divider: usize, fraction: f32) { + if let Err(error) = self.layout.center.resize_divider(axis_path, divider, fraction) { + self.problem = Some(error.to_string()); + } + } + + pub fn drop_item( + &mut self, + item: ItemId, + source: crate::workspace::PaneId, + target: crate::workspace::PaneId, + index: Option, + ) { + if self.layout.pane_for_item(item) != Some(source) { + self.drag_target = None; + return; + } + let direction = self + .drag_target + .filter(|(pane, _)| *pane == target) + .and_then(|(_, direction)| direction); + self.drag_target = None; + let destination = match direction { + Some(direction) => match self.layout.split_pane(target, direction) { + Ok(pane) => pane, + Err(error) => { + self.problem = Some(error.to_string()); + return; + } + }, + None => target, + }; + if let Err(error) = self.layout.move_item(item, destination, index) { + self.problem = Some(error.to_string()); + } + } + + pub fn prepare_drop_destination( + &mut self, + target: crate::workspace::PaneId, + ) -> Option { + let direction = self + .drag_target + .take() + .filter(|(pane, _)| *pane == target) + .and_then(|(_, direction)| direction); + match direction { + Some(direction) => match self.layout.split_pane(target, direction) { + Ok(pane) => Some(pane), + Err(error) => { + self.problem = Some(error.to_string()); + None + } + }, + None => Some(target), + } + } + + /// Zed's split-and-move action creates the neighboring pane and moves the + /// active item into it. Items remain unique; terminals are never cloned. + pub fn split_and_move(&mut self, direction: SplitDirection) { + let source = self.layout.active_pane(); + let active = self.layout.pane(source).and_then(|pane| pane.active()); + match self.layout.split_pane(source, direction) { + Ok(destination) => { + if let Some(active) = active + && let Err(error) = self.layout.move_item(active, destination, None) + { + self.problem = Some(error.to_string()); + } + } + Err(error) => self.problem = Some(error.to_string()), + } + } + + pub fn move_active_to_pane(&mut self, direction: SplitDirection) { + let source = self.layout.active_pane(); + let active = self.layout.pane(source).and_then(|pane| pane.active()); + let destination = self.layout.pane_in_direction(direction); + if let (Some(active), Some(destination)) = (active, destination) + && let Err(error) = self.layout.move_item(active, destination, None) + { + self.problem = Some(error.to_string()); + } + } + + pub fn join_active_into_next(&mut self) { + let source = self.layout.active_pane(); + let destination = + [SplitDirection::Right, SplitDirection::Down, SplitDirection::Left, SplitDirection::Up] + .into_iter() + .find_map(|direction| self.layout.pane_in_direction(direction)); + if let Some(destination) = destination + && let Err(error) = self.layout.join_pane(source, destination) + { + self.problem = Some(error.to_string()); + } + } + + pub fn toggle_zoom(&mut self) { + let active = self.layout.active_pane(); + if let Err(error) = self.layout.center.toggle_maximized(active) { + self.problem = Some(error.to_string()); + } + } + + pub fn entries(&self, space: gpui::EntityId) -> Vec { + self.layout + .panes() + .flat_map(|pane| { + pane.items().iter().filter_map(move |id| { + let item = self.items.get(id)?; + Some(Entry { + space, + space_key: self.key(), + key: *id, + pane: pane.id, + title: item.title(), + agent: item.agent(), + ended: item.ended(), + selected: pane.active() == Some(*id) + && self.layout.active_pane() == pane.id, + closable: true, + }) + }) + }) + .collect() + } + + pub fn pane_entries(&self, space: gpui::EntityId) -> Vec { + self.layout + .panes() + .map(|pane| PaneEntries { + id: pane.id, + entries: pane + .items() + .iter() + .filter_map(|id| { + let item = self.items.get(id)?; + Some(Entry { + space, + space_key: self.key(), + key: *id, + pane: pane.id, + title: item.title(), + agent: item.agent(), + ended: item.ended(), + selected: pane.active() == Some(*id) + && self.layout.active_pane() == pane.id, + closable: true, + }) + }) + .collect(), + }) + .collect() + } + + pub fn pane_item_ids(&self, pane: crate::workspace::PaneId) -> Vec { + self.layout.pane(pane).map(|pane| pane.items().to_vec()).unwrap_or_default() + } + + pub fn all_item_ids(&self) -> Vec { + self.layout.item_ids().collect() + } + + pub fn plugin_item_ids(&self, plugin: &str) -> Vec { + self.items + .iter() + .filter_map(|(id, item)| { + item.as_plugin().filter(|item| item.contribution.plugin == plugin).map(|_| *id) + }) + .collect() + } + + pub fn close_targets(&self, ids: &[ItemId]) -> Vec<(ItemId, Option)> { + ids.iter() + .filter_map(|id| { + self.items + .get(id) + .map(|item| (*id, item.as_session().map(|item| item.session.id().clone()))) + }) + .collect() + } + + pub fn finish_bulk_close(&mut self, ids: &[ItemId]) { + for id in ids { + self.remove_item(*id); + } + } + + pub fn act(&mut self, action: Action, cx: &mut Context) { + match action { + Action::Select { item, .. } => { + if let Err(error) = self.layout.activate_item(item) { + self.problem = Some(error.to_string()); + } + self.fit_items(); + } + Action::Close { item, .. } => self.close_item(item, cx), + Action::MoveToPane { item, source, target, .. } => { + self.drop_item(item, source, target, None) + } + Action::New + | Action::NewInSpace { .. } + | Action::ClosePane { .. } + | Action::CloseSpace { .. } + | Action::RenameSpace { .. } + | Action::LocateSpace { .. } + | Action::ToggleMode + | Action::ToggleSidebarScope => {} + } + cx.notify(); + } + + pub fn open_plugin(&mut self, plugin: PluginItem, cx: &mut Context) -> ItemId { + let id = self.layout.alloc_item(); + self.open_plugin_at(id, plugin, cx); + id + } + + pub fn open_plugin_in( + &mut self, + plugin: PluginItem, + pane: crate::workspace::PaneId, + index: Option, + cx: &mut Context, + ) -> ItemId { + let id = self.layout.alloc_item(); + self.items.insert(id, Item::Plugin(plugin)); + if let Err(error) = self.layout.add_item(id, Some(pane), index) { + self.items.remove(&id); + self.problem = Some(error.to_string()); + } + cx.notify(); + id + } + + pub fn cloneable_plugin( + &self, + item: ItemId, + ) -> Option<(zeddy_plugin::PaneKey, String, Option)> { + let plugin = self.items.get(&item)?.as_plugin()?; + plugin.can_clone.then(|| { + (plugin.contribution.clone(), plugin.title.clone(), plugin.bound_session.clone()) + }) + } + + pub fn open_plugin_at(&mut self, id: ItemId, plugin: PluginItem, cx: &mut Context) { + self.items.insert(id, Item::Plugin(plugin)); + if self.layout.pane_for_item(id).is_none() + && let Err(error) = self.layout.add_item(id, None, None) + { + self.items.remove(&id); + self.problem = Some(error.to_string()); + } + cx.notify(); + } + + pub fn plugin_item(&self, contribution: &zeddy_plugin::PaneKey) -> Option { + self.items.iter().find_map(|(id, item)| { + item.as_plugin().filter(|item| &item.contribution == contribution).map(|_| *id) + }) + } + + pub fn activate_plugin(&mut self, contribution: &zeddy_plugin::PaneKey) -> bool { + let Some(item) = self.plugin_item(contribution) else { + return false; + }; + self.layout.activate_item(item).is_ok() + } + + pub fn take_restoring_plugins(&mut self) -> Vec { + std::mem::take(&mut self.restoring_plugins) + } + + pub fn restore_plugin( + &mut self, + record: &PersistedItem, + plugin: PluginItem, + cx: &mut Context, + ) -> bool { + let Some(item) = + self.layout.item_ids().find(|candidate| candidate.get() == record.item_id()) + else { + return false; + }; + self.open_plugin_at(item, plugin, cx); + true + } + + pub fn remove_plugin_placeholders(&mut self, records: &[PersistedItem]) { + for record in records { + let Some(item) = + self.layout.item_ids().find(|candidate| candidate.get() == record.item_id()) + else { + continue; + }; + if !self.items.contains_key(&item) { + let _ = self.layout.remove_item(item); + } + } + } + + pub fn send_active(&mut self, bytes: &[u8], cx: &mut Context) { + let Some(id) = self.active() else { + return; + }; + if let Some(session) = self.items.get_mut(&id).and_then(Item::as_session_mut) + && let Err(error) = session.session.send(bytes) + { + self.problem = Some(error.to_string()); + cx.notify(); + } + } + + pub fn fit_items(&mut self) { + for item in self.items.values_mut() { + let Some(item) = item.as_session_mut() else { + continue; + }; + let Some(size) = item.fit.get() else { + continue; + }; + if let Err(error) = item.session.resize(size) { + self.problem = Some(error.to_string()); + } + } + } + + /// Attach sessions discovered by the parent's one backend snapshot. + /// Process spawning and stream setup stay off the frame thread. + pub fn adopt(&mut self, infos: Vec, cx: &mut Context) { + let infos: Vec<_> = + infos.into_iter().filter(|info| !self.sessions.contains_key(&info.id)).collect(); + if infos.is_empty() { + return; + } + + let client = self.client.clone(); + let wakeups = self.wakeup_tx.clone(); + let size = zeddy_vt::Size::default(); + let executor = cx.background_executor().clone(); + let restored_ids: HashMap<_, _> = infos + .iter() + .filter_map(|info| { + self.restoring_sessions.remove(&info.id.0).map(|item| (info.id.clone(), item)) + }) + .collect(); + let stale: Vec<_> = self.restoring_sessions.drain().map(|(_, item)| item).collect(); + for item in stale { + let _ = self.layout.remove_item(item); + } + cx.spawn(async move |this, cx| { + let attached = executor + .spawn(async move { + infos + .into_iter() + .map(|info| { + let restored = restored_ids.get(&info.id).copied(); + (restored, Session::attach(&client, info, size, wakeups.clone())) + }) + .collect::>() + }) + .await; + let _ = this.update(cx, |this, cx| { + for (restored, result) in attached { + match result { + Ok(session) => this.insert_session_with_id(session, restored), + Err(error) => { + if let Some(item) = restored { + let _ = this.layout.remove_item(item); + } + this.problem = Some(error.to_string()); + } + } + } + cx.notify(); + }); + }) + .detach(); + } + + pub fn start_session(&mut self, cx: &mut Context) { + if self.starting { + return; + } + if !self.path.is_dir() { + self.problem = Some(format!( + "{} is unavailable. Locate the space folder before opening a session.", + self.path.display() + )); + cx.notify(); + return; + } + self.starting = true; + self.problem = None; + cx.notify(); + + let client = self.client.clone(); + let workspace = self.workspace.clone(); + let path = self.path.clone(); + let label = self.name.clone(); + let wakeups = self.wakeup_tx.clone(); + let size = zeddy_vt::Size::default(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let result = executor + .spawn(async move { + let info = match workspace { + Some(workspace) => client.start_session(&workspace, None), + None => client.create_workspace(&path, Some(&label)), + }?; + Session::attach(&client, info, size, wakeups) + }) + .await; + let _ = this.update(cx, |this, cx| { + this.starting = false; + match result { + Ok(session) => { + this.insert_session(session); + this.problem = None; + } + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + }); + }) + .detach(); + } + + fn insert_session(&mut self, session: Session) { + self.insert_session_with_id(session, None); + } + + fn insert_session_with_id(&mut self, session: Session, restored: Option) { + self.workspace = Some(session.info.workspace.clone()); + let backend_id = session.id().clone(); + if self.sessions.contains_key(&backend_id) { + return; + } + let id = restored.unwrap_or_else(|| self.layout.alloc_item()); + self.items.insert(id, Item::Session(SessionItem::new(session))); + if self.layout.pane_for_item(id).is_none() + && let Err(error) = self.layout.add_item(id, None, None) + { + self.items.remove(&id); + self.problem = Some(error.to_string()); + return; + } + self.sessions.insert(backend_id, id); + } + + fn close_item(&mut self, id: ItemId, cx: &mut Context) { + let Some(item) = self.items.get(&id) else { + return; + }; + let Some(backend_id) = item.as_session().map(|item| item.session.id().clone()) else { + self.remove_item(id); + cx.notify(); + return; + }; + if !self.closing.insert(id.clone()) { + return; + } + let client = self.client.clone(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + let asked = backend_id.clone(); + let result = executor.spawn(async move { client.close_session(&asked) }).await; + let _ = this.update(cx, |this, cx| { + this.closing.remove(&id); + match result { + Ok(()) => this.remove_item(id), + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + }); + }) + .detach(); + } + + fn remove_item(&mut self, id: ItemId) { + let Some(mut item) = self.items.remove(&id) else { + return; + }; + if let Some(session) = item.as_session_mut() { + let backend_id = session.session.id().clone(); + self.sessions.remove(&backend_id); + session.session.release(); + let dependents: Vec<_> = self + .items + .iter() + .filter_map(|(id, item)| { + item.as_plugin().and_then(|plugin| { + (plugin.bound_session.as_ref() == Some(&backend_id)).then_some(*id) + }) + }) + .collect(); + for dependent in dependents { + self.remove_item(dependent); + } + } + if let Err(error) = self.layout.remove_item(id) { + self.problem = Some(error.to_string()); + } + if self.sessions.is_empty() { + self.workspace = None; + } + } + + /// Remove terminal items after the daemon that owned their PTYs died. + /// Plugin items and the space itself survive; no shell is recreated under + /// a dead tab's identity. + pub fn drop_dead_sessions(&mut self) { + let terminal_items: Vec<_> = self + .items + .iter() + .filter_map(|(id, item)| item.as_session().map(|_| *id)) + .chain(self.restoring_sessions.values().copied()) + .collect(); + self.restoring_sessions.clear(); + for id in terminal_items { + self.remove_item(id); + } + self.workspace = None; + self.starting = false; + self.closing.clear(); + } +} + +impl Drop for Space { + fn drop(&mut self) { + for item in self.items.values_mut() { + if let Some(item) = item.as_session_mut() { + item.session.release(); + } + } + } +} + +pub fn name_for(kind: Kind, path: &std::path::Path) -> String { + match kind { + Kind::AdHoc => "Ad-hoc sessions".to_owned(), + Kind::Registered => spaces::display_name(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_synthetic_space_has_the_product_name_from_the_sketch() { + assert_eq!(name_for(Kind::AdHoc, std::path::Path::new("/home/op")), "Ad-hoc sessions"); + } +} diff --git a/crates/zeddy/src/spaces.rs b/crates/zeddy/src/spaces.rs new file mode 100644 index 00000000..885dec95 --- /dev/null +++ b/crates/zeddy/src/spaces.rs @@ -0,0 +1,449 @@ +//! The persisted list of folders zeddy calls spaces. +//! +//! This is a model below GPUI: folder picking belongs to the window, while +//! validation and persistence are testable without one. Chartr-zeddy is +//! deliberately isolated from older Chartr installations, so its registry +//! lives under the `chartr-zeddy` configuration namespace. + +use std::{ + ffi::OsString, + fmt, fs, + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +pub const SPACES_FILE: &str = "spaces.toml"; + +const HEADER: &str = "\ +# chartr-zeddy's registered spaces, in sidebar order. Every folder here is one the +# operator added; nothing authoritative lives in this file, so deleting it costs +# re-adding the folders and nothing else. +"; + +pub fn spaces_file() -> Result { + Ok(config_root()?.join(SPACES_FILE)) +} + +pub(crate) fn config_root() -> Result { + config_root_from(std::env::var_os("XDG_CONFIG_HOME"), std::env::home_dir()) +} + +fn config_root_from(xdg: Option, home: Option) -> Result { + if let Some(xdg) = xdg.filter(|xdg| Path::new(xdg).is_absolute()) { + return Ok(PathBuf::from(xdg).join("chartr-zeddy")); + } + home.filter(|home| !home.as_os_str().is_empty()) + .map(|home| home.join(".config/chartr-zeddy")) + .ok_or(Error::NoConfigRoot) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Space { + path: PathBuf, + name: String, + // Unknown keys belong to older or newer chartr versions. Carry them + // through so this rewrite never eats another version's state. + extra: toml::Table, +} + +impl Space { + fn new(path: PathBuf, name: Option, extra: toml::Table) -> Self { + let name = + name.filter(|name| !name.trim().is_empty()).unwrap_or_else(|| display_name(&path)); + Self { path, name, extra } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn name(&self) -> &str { + &self.name + } +} + +pub fn display_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) +} + +#[derive(Debug, Clone)] +pub struct Registry { + file: PathBuf, + spaces: Vec, + extra: toml::Table, +} + +impl Registry { + pub fn load(file: impl Into) -> Result { + let file = file.into(); + let text = match fs::read_to_string(&file) { + Ok(text) => text, + Err(source) if source.kind() == io::ErrorKind::NotFound => { + return Ok(Self { file, spaces: Vec::new(), extra: toml::Table::new() }); + } + Err(source) => return Err(Error::io(file, "reading", source)), + }; + + let document: Document = toml::from_str(&text) + .map_err(|source| Error::Malformed { path: file.clone(), source })?; + Ok(Self { spaces: seat(&file, document.spaces)?, extra: document.extra, file }) + } + + pub fn spaces(&self) -> &[Space] { + &self.spaces + } + + /// Register a folder, appending it to file/sidebar order. + /// + /// Re-registering is selection, not duplication, and therefore performs no + /// write. A failed write rolls the in-memory row back. + pub fn register(&mut self, path: impl AsRef) -> Result { + let path = absolute(path.as_ref())?; + recordable(&path)?; + let metadata = fs::metadata(&path) + .map_err(|source| Error::NotAFolder { path: path.clone(), source: Some(source) })?; + if !metadata.is_dir() { + return Err(Error::NotAFolder { path, source: None }); + } + if self.spaces.iter().any(|space| same_path(&space.path, &path)) { + return Ok(path); + } + + self.spaces.push(Space::new(path.clone(), None, toml::Table::new())); + if let Err(error) = self.save() { + self.spaces.pop(); + return Err(error); + } + Ok(path) + } + + /// Forget a registered folder without touching the folder itself. + pub fn remove(&mut self, path: impl AsRef) -> Result { + let Some(index) = + self.spaces.iter().position(|space| same_path(space.path(), path.as_ref())) + else { + return Ok(false); + }; + let removed = self.spaces.remove(index); + if let Err(error) = self.save() { + self.spaces.insert(index, removed); + return Err(error); + } + Ok(true) + } + + pub fn rename(&mut self, path: impl AsRef, name: String) -> Result<(), Error> { + let name = name.trim(); + if name.is_empty() { + return Err(Error::BadName); + } + let Some(index) = + self.spaces.iter().position(|space| same_path(space.path(), path.as_ref())) + else { + return Ok(()); + }; + let old = std::mem::replace(&mut self.spaces[index].name, name.to_owned()); + if let Err(error) = self.save() { + self.spaces[index].name = old; + return Err(error); + } + Ok(()) + } + + pub fn relocate( + &mut self, + old_path: impl AsRef, + new_path: impl AsRef, + ) -> Result { + let new_path = absolute(new_path.as_ref())?; + recordable(&new_path)?; + if !new_path.is_dir() { + return Err(Error::NotAFolder { path: new_path, source: None }); + } + let Some(index) = + self.spaces.iter().position(|space| same_path(space.path(), old_path.as_ref())) + else { + return Ok(new_path); + }; + if self + .spaces + .iter() + .enumerate() + .any(|(candidate, space)| candidate != index && same_path(space.path(), &new_path)) + { + return Err(Error::DuplicateFolder(new_path)); + } + let old = std::mem::replace(&mut self.spaces[index].path, new_path.clone()); + if let Err(error) = self.save() { + self.spaces[index].path = old; + return Err(error); + } + Ok(new_path) + } + + fn save(&self) -> Result<(), Error> { + let parent = self.file.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .map_err(|source| Error::io(parent.to_path_buf(), "creating", source))?; + + let document = Document { + spaces: self + .spaces + .iter() + .map(|space| { + Ok(Record { + path: recordable(&space.path)?.to_owned(), + name: (space.name != display_name(&space.path)).then(|| space.name.clone()), + extra: space.extra.clone(), + }) + }) + .collect::>()?, + extra: self.extra.clone(), + }; + let body = toml::to_string(&document).map_err(Error::Encode)?; + + // The old implementation stages beside the destination and atomically + // persists it. Keep that exact transaction boundary here. + let mut staged = tempfile::NamedTempFile::new_in(parent) + .map_err(|source| Error::io(self.file.clone(), "staging", source))?; + staged + .write_all(format!("{HEADER}\n{body}").as_bytes()) + .and_then(|_| staged.flush()) + .map_err(|source| Error::io(self.file.clone(), "staging", source))?; + staged + .persist(&self.file) + .map_err(|error| Error::io(self.file.clone(), "replacing", error.error))?; + Ok(()) + } +} + +pub fn same_path(a: &Path, b: &Path) -> bool { + resolved(a) == resolved(b) +} + +fn resolved(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_owned()) +} + +fn absolute(path: &Path) -> Result { + std::path::absolute(path) + .map_err(|source| Error::NotAFolder { path: path.to_path_buf(), source: Some(source) }) +} + +fn recordable(path: &Path) -> Result<&str, Error> { + path.to_str().ok_or_else(|| Error::NotUnicode { path: path.to_path_buf() }) +} + +/// Load file order, with the one migration supported by the old registry: +/// legacy integer `order` keys sort first and are then removed. +fn seat(file: &Path, records: Vec) -> Result, Error> { + let mut records: Vec<(i64, Record)> = records + .into_iter() + .map(|mut record| { + let order = match record.extra.remove("order") { + Some(toml::Value::Integer(order)) => order, + Some(other) => { + record.extra.insert("order".into(), other); + i64::MAX + } + None => i64::MAX, + }; + (order, record) + }) + .collect(); + records.sort_by_key(|(order, _)| *order); + + let mut spaces: Vec = Vec::with_capacity(records.len()); + for (_, record) in records { + let path = PathBuf::from(&record.path); + if !path.is_absolute() { + return Err(Error::NotAbsolute { file: file.to_path_buf(), path: record.path }); + } + if spaces.iter().all(|space| !same_path(&space.path, &path)) { + spaces.push(Space::new(path, record.name, record.extra)); + } + } + Ok(spaces) +} + +#[derive(Debug, Deserialize, Serialize)] +struct Document { + #[serde(default, rename = "space")] + spaces: Vec, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug, Deserialize, Serialize)] +struct Record { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(flatten)] + extra: toml::Table, +} + +#[derive(Debug)] +pub enum Error { + Io { path: PathBuf, action: &'static str, source: io::Error }, + Malformed { path: PathBuf, source: toml::de::Error }, + NotAbsolute { file: PathBuf, path: String }, + Encode(toml::ser::Error), + NotAFolder { path: PathBuf, source: Option }, + NotUnicode { path: PathBuf }, + NoConfigRoot, + BadName, + DuplicateFolder(PathBuf), +} + +impl Error { + fn io(path: PathBuf, action: &'static str, source: io::Error) -> Self { + Self::Io { path, action, source } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, action, source } => { + write!(f, "{action} {}: {source}", path.display()) + } + Self::Malformed { path, .. } => { + write!(f, "{} is not a space registry chartr can read", path.display()) + } + Self::NotAbsolute { file, path } => write!( + f, + "{} names the relative path {path:?}; every space is an absolute path", + file.display() + ), + Self::Encode(source) => write!(f, "encoding the space registry: {source}"), + Self::NotAFolder { path, source: Some(source) } => { + write!(f, "{} is not a folder zeddy can register: {source}", path.display()) + } + Self::NotAFolder { path, source: None } => { + write!(f, "{} is a file, not a folder", path.display()) + } + Self::NotUnicode { path } => write!( + f, + "{} is not a name the registry file can hold: it is not Unicode", + path.display() + ), + Self::NoConfigRoot => write!( + f, + "neither XDG_CONFIG_HOME nor a home directory is set, so there is nowhere for {SPACES_FILE} to live" + ), + Self::BadName => write!(f, "a space name cannot be empty"), + Self::DuplicateFolder(path) => { + write!(f, "{} is already registered as another space", path.display()) + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Malformed { source, .. } => Some(source), + Self::Encode(source) => Some(source), + Self::NotAFolder { source, .. } => { + source.as_ref().map(|source| source as &(dyn std::error::Error + 'static)) + } + Self::NotAbsolute { .. } + | Self::NotUnicode { .. } + | Self::NoConfigRoot + | Self::BadName + | Self::DuplicateFolder(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_root_is_isolated_from_other_chartr_installations() { + assert_eq!( + config_root_from(Some("/xdg".into()), Some("/home/op".into())).unwrap(), + PathBuf::from("/xdg/chartr-zeddy") + ); + assert_eq!( + config_root_from(None, Some("/home/op".into())).unwrap(), + PathBuf::from("/home/op/.config/chartr-zeddy") + ); + } + + #[test] + fn registration_round_trips_and_deduplicates() { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("project"); + fs::create_dir(&folder).unwrap(); + let file = temp.path().join("spaces.toml"); + let mut registry = Registry::load(&file).unwrap(); + + registry.register(&folder).unwrap(); + registry.register(&folder).unwrap(); + + let loaded = Registry::load(file).unwrap(); + assert_eq!(loaded.spaces().len(), 1); + assert_eq!(loaded.spaces()[0].name(), "project"); + } + + #[test] + fn removing_a_space_only_changes_the_registry() { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("project"); + fs::create_dir(&folder).unwrap(); + let file = temp.path().join("spaces.toml"); + let mut registry = Registry::load(&file).unwrap(); + registry.register(&folder).unwrap(); + + assert!(registry.remove(&folder).unwrap()); + assert!(folder.is_dir(), "the project folder is not registry data"); + assert!(Registry::load(file).unwrap().spaces().is_empty()); + } + + #[test] + fn legacy_order_is_honoured_once() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("spaces.toml"); + fs::write( + &file, + "[[space]]\npath = \"/second\"\norder = 2\n\n[[space]]\npath = \"/first\"\norder = 1\n", + ) + .unwrap(); + let registry = Registry::load(file).unwrap(); + assert_eq!(registry.spaces()[0].name(), "first"); + assert_eq!(registry.spaces()[1].name(), "second"); + } + + #[test] + fn keys_owned_by_other_chartr_versions_survive_a_write() { + let temp = tempfile::tempdir().unwrap(); + let existing = temp.path().join("existing"); + let added = temp.path().join("added"); + fs::create_dir(&existing).unwrap(); + fs::create_dir(&added).unwrap(); + let file = temp.path().join("spaces.toml"); + fs::write( + &file, + format!( + "future_top = \"kept\"\n\n[[space]]\npath = {:?}\nfuture_row = 42\n", + existing.to_string_lossy() + ), + ) + .unwrap(); + + let mut registry = Registry::load(&file).unwrap(); + registry.register(added).unwrap(); + let written = fs::read_to_string(file).unwrap(); + + assert!(written.contains("future_top = \"kept\"")); + assert!(written.contains("future_row = 42")); + } +} diff --git a/crates/zeddy/src/web_plugin.rs b/crates/zeddy/src/web_plugin.rs new file mode 100644 index 00000000..e878b2f0 --- /dev/null +++ b/crates/zeddy/src/web_plugin.rs @@ -0,0 +1,566 @@ +//! The web-plugin pane host. +//! +//! A web contribution is an operating-system webview parented to the GPUI +//! window. The element below follows the same visibility lease used by +//! Chartr-rs's browser pane: GPUI owns layout while Wry owns the native pixels. + +#[cfg(target_os = "linux")] +use std::time::Duration; +use std::{ + borrow::Cow, + cell::RefCell, + path::{Path, PathBuf}, + rc::{Rc, Weak}, +}; + +use gpui::{ + AnyView, App, AppContext as _, Bounds, Context, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, ParentElement as _, Pixels, Render, Size, Style, + Styled as _, Window, div, +}; +use serde::{Deserialize, Serialize}; +use zeddy_plugin::manifest::Permissions; +use zeddy_plugin_host::FileBroker; + +use crate::session::SessionAccess; + +#[cfg(any(target_os = "macos", target_os = "linux"))] +use wry::{ + Rect, WebViewBuilder, + dpi::{LogicalPosition, LogicalSize, Position, Size as WrySize}, + http::{Response, header}, +}; + +pub fn view( + entry: PathBuf, + broker: FileBroker, + permissions: Permissions, + session: Option, + window: &mut Window, + cx: &mut App, +) -> AnyView { + cx.new(|cx| WebPluginView::new(entry, broker, permissions, session, window, cx)).into() +} + +struct WebPluginView { + #[cfg(any(target_os = "macos", target_os = "linux"))] + webview: Option>, + #[cfg(target_os = "linux")] + _gtk_pump: gpui::Task<()>, + error: Option, +} + +impl WebPluginView { + fn new( + entry: PathBuf, + broker: FileBroker, + permissions: Permissions, + session: Option, + window: &Window, + _cx: &mut Context, + ) -> Self { + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (entry, broker, permissions, session, window, _cx); + return Self { error: Some("Web plugins are supported on macOS and Linux.".into()) }; + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + #[cfg(target_os = "linux")] + let gtk_pump = Self::pump_gtk(_cx); + #[cfg(target_os = "linux")] + if let Err(error) = gtk::init() { + return Self { + webview: None, + _gtk_pump: gtk_pump, + error: Some(format!("Could not initialize GTK: {error}")), + }; + } + + let Some(root) = entry.parent().and_then(|path| path.canonicalize().ok()) else { + return Self { + webview: None, + #[cfg(target_os = "linux")] + _gtk_pump: gtk_pump, + error: Some(format!("Plugin entry is unavailable: {}", entry.display())), + }; + }; + let entry_name = + entry.file_name().and_then(|name| name.to_str()).unwrap_or("index.html"); + let root_for_protocol = root.clone(); + let webview_slot = Rc::new(RefCell::new(None::>)); + let responder = webview_slot.clone(); + let builder = WebViewBuilder::new() + .with_custom_protocol("chartr-plugin".into(), move |_, request| { + asset_response(&root_for_protocol, request.uri().path()) + }) + .with_initialization_script(BRIDGE) + .with_ipc_handler(move |request| { + let response = + handle_request(&broker, &permissions, session.as_ref(), request.body()); + if let Some(webview) = responder.borrow().as_ref().and_then(Weak::upgrade) + && let Ok(response) = serde_json::to_string(&response) + { + let _ = + webview.evaluate_script(&format!("window.__chartrReply({response})")); + } + }) + .with_navigation_handler(|url| url.starts_with("chartr-plugin://plugin/")) + .with_new_window_req_handler(|_, _| wry::NewWindowResponse::Deny) + .with_bounds(Rect { + position: Position::Logical(LogicalPosition::new(0.0, 0.0)), + size: WrySize::Logical(LogicalSize::new(1.0, 1.0)), + }) + .with_visible(false) + .with_focused(false) + .with_url(format!("chartr-plugin://plugin/{entry_name}")); + + let webview = match builder.build_as_child(window) { + Ok(webview) => Rc::new(webview), + Err(error) => { + return Self { + webview: None, + #[cfg(target_os = "linux")] + _gtk_pump: gtk_pump, + error: Some(format!("Could not create the plugin webview: {error}")), + }; + } + }; + *webview_slot.borrow_mut() = Some(Rc::downgrade(&webview)); + Self { + webview: Some(webview), + #[cfg(target_os = "linux")] + _gtk_pump: gtk_pump, + error: None, + } + } + } + + #[cfg(target_os = "linux")] + fn pump_gtk(cx: &mut Context) -> gpui::Task<()> { + // Wry's documented non-GTK-parent integration requires advancing GTK + // alongside the host event loop. The task owns no WebView and ends as + // soon as this pane entity is dropped. + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(Duration::from_millis(16)).await; + if this + .update(cx, |_, _| { + while gtk::events_pending() { + gtk::main_iteration_do(false); + } + }) + .is_err() + { + break; + } + } + }) + } +} + +impl Render for WebPluginView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let mut root = div().size_full(); + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Some(webview) = self.webview.clone() { + root = root.child(NativeWebViewElement::new(webview, "chartr-web-plugin")); + } + if let Some(error) = &self.error { + root = root.flex().items_center().justify_center().child(error.clone()); + } + root + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn asset_response(root: &Path, uri_path: &str) -> Response> { + let relative = uri_path.trim_start_matches('/'); + let relative = relative.strip_prefix("plugin/").unwrap_or(relative); + let result = (|| { + let path = root.join(relative).canonicalize().map_err(|error| error.to_string())?; + if !path.starts_with(root) || !path.is_file() { + return Err("asset escapes the plugin directory".to_owned()); + } + std::fs::read(&path).map(|body| (path, body)).map_err(|error| error.to_string()) + })(); + match result { + Ok((path, body)) => Response::builder() + .header(header::CONTENT_TYPE, content_type(&path)) + .header( + "Content-Security-Policy", + "default-src 'self' data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'", + ) + .body(Cow::Owned(body)) + .expect("valid asset response"), + Err(error) => Response::builder() + .status(404) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Cow::Owned(error.into_bytes())) + .expect("valid error response"), + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()).unwrap_or_default() { + "html" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "json" => "application/json", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "wasm" => "application/wasm", + _ => "application/octet-stream", + } +} + +// The API exists from the first script tick. Host actions are intentionally +// denied until an instance receives an explicit broker; web content cannot +// silently fall back to direct network access because the CSP blocks it. +const BRIDGE: &str = r#" +(() => { + let next = 1; + const pending = new Map(); + window.__chartrReply = response => { + const pair = pending.get(response.id); + if (!pair) return; + pending.delete(response.id); + response.ok ? pair[0](response.value) : pair[1](new Error(response.error)); + }; + const invoke = (action, options = {}) => new Promise((resolve, reject) => { + const id = next++; + pending.set(id, [resolve, reject]); + window.ipc.postMessage(JSON.stringify({ id, action, ...options })); + }); + Object.defineProperty(window, "chartr", { value: Object.freeze({ invoke }) }); +})(); +"#; + +#[derive(Deserialize)] +struct HostRequest { + id: u64, + action: String, + #[serde(default)] + path: String, + #[serde(default)] + data: String, + #[serde(default)] + url: String, + #[serde(default)] + command: String, + #[serde(default)] + args: Vec, +} + +#[derive(Serialize)] +struct HostResponse { + id: u64, + ok: bool, + value: serde_json::Value, + error: String, +} + +fn handle_request( + broker: &FileBroker, + permissions: &Permissions, + session: Option<&SessionAccess>, + encoded: &str, +) -> HostResponse { + let request: HostRequest = match serde_json::from_str(encoded) { + Ok(request) => request, + Err(error) => { + return HostResponse { + id: 0, + ok: false, + value: serde_json::Value::Null, + error: format!("Invalid host request: {error}"), + }; + } + }; + let result = match request.action.as_str() { + "project.read" => broker + .project_path(Path::new(&request.path), false) + .and_then(|path| { + std::fs::read_to_string(path).map_err(zeddy_plugin_host::BrokerError::Io) + }) + .map(serde_json::Value::String) + .map_err(|error| error.to_string()), + "project.write" => broker + .project_path(Path::new(&request.path), true) + .and_then(|path| { + std::fs::write(path, request.data.as_bytes()) + .map_err(zeddy_plugin_host::BrokerError::Io) + }) + .map(|_| serde_json::Value::Bool(true)) + .map_err(|error| error.to_string()), + "data.read" => broker + .data_path(Path::new(&request.path), false) + .and_then(|path| { + std::fs::read_to_string(path).map_err(zeddy_plugin_host::BrokerError::Io) + }) + .map(serde_json::Value::String) + .map_err(|error| error.to_string()), + "data.write" => broker + .data_path(Path::new(&request.path), true) + .and_then(|path| { + std::fs::write(path, request.data.as_bytes()) + .map_err(zeddy_plugin_host::BrokerError::Io) + }) + .map(|_| serde_json::Value::Bool(true)) + .map_err(|error| error.to_string()), + "network.fetch" => fetch(&request.url, &permissions.network), + "process.run" if permissions.process => std::process::Command::new(&request.command) + .args(&request.args) + .output() + .map(|output| { + serde_json::json!({ + "status": output.status.code(), + "stdout": String::from_utf8_lossy(&output.stdout), + "stderr": String::from_utf8_lossy(&output.stderr), + }) + }) + .map_err(|error| error.to_string()), + "process.run" => Err("the plugin did not declare process access".to_owned()), + "session.metadata" if permissions.session => session + .map(|session| { + serde_json::json!({ + "id": session.info.id.0, + "workspace": session.info.workspace.0, + "title": session.info.title, + "agent": session.info.agent, + "cwd": session.info.cwd, + }) + }) + .ok_or_else(|| "this plugin instance is not bound to a session".to_owned()), + "session.send" if permissions.session => session + .ok_or_else(|| "this plugin instance is not bound to a session".to_owned()) + .and_then(|session| { + session + .send(request.data.as_bytes()) + .map(|_| serde_json::Value::Bool(true)) + .map_err(|error| error.to_string()) + }), + "session.metadata" | "session.send" => { + Err("the plugin did not declare session access".to_owned()) + } + _ => Err(format!("unknown host action `{}`", request.action)), + }; + match result { + Ok(value) => HostResponse { id: request.id, ok: true, value, error: String::new() }, + Err(error) => { + HostResponse { id: request.id, ok: false, value: serde_json::Value::Null, error } + } + } +} + +fn fetch(requested: &str, allowed: &[String]) -> Result { + let url = url::Url::parse(requested).map_err(|error| error.to_string())?; + if !matches!(url.scheme(), "http" | "https") { + return Err("only HTTP and HTTPS network actions are allowed".to_owned()); + } + let host = url.host_str().ok_or_else(|| "the URL has no host".to_owned())?; + if !allowed.iter().any(|entry| { + let allowed_host = url::Url::parse(entry) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_else(|| entry.trim_start_matches("*.").to_owned()); + host == allowed_host + || (entry.starts_with("*.") && host.ends_with(&format!(".{allowed_host}"))) + }) { + return Err(format!("network access to `{host}` is not declared")); + } + let mut response = ureq::get(requested).call().map_err(|error| error.to_string())?; + let status = response.status().as_u16(); + let body = response.body_mut().read_to_string().map_err(|error| error.to_string())?; + Ok(serde_json::json!({ "status": status, "body": body })) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +struct NativeWebViewElement { + webview: Rc, + id: ElementId, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl NativeWebViewElement { + fn new(webview: Rc, id: impl Into) -> Self { + Self { webview, id: id.into() } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl IntoElement for NativeWebViewElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +struct VisibleWebView { + webview: Weak, + frame: Option, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl Drop for VisibleWebView { + fn drop(&mut self) { + if let Some(webview) = self.webview.upgrade() { + let _ = webview.focus_parent(); + let _ = webview.set_visible(false); + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[derive(Clone, Copy, PartialEq, Eq)] +struct NativeFrame { + x: i32, + y: i32, + width: i32, + height: i32, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl NativeFrame { + fn snapped(bounds: Bounds) -> Self { + let left = bounds.left().as_f32().round() as i32; + let top = bounds.top().as_f32().round() as i32; + let right = bounds.right().as_f32().round() as i32; + let bottom = bounds.bottom().as_f32().round() as i32; + Self { x: left, y: top, width: (right - left).max(0), height: (bottom - top).max(0) } + } + + fn wry(self) -> Rect { + Rect { + position: Position::Logical(LogicalPosition::new(f64::from(self.x), f64::from(self.y))), + size: WrySize::Logical(LogicalSize::new(f64::from(self.width), f64::from(self.height))), + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl Element for NativeWebViewElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style { size: Size::full(), ..Style::default() }, [], cx), ()) + } + + fn prepaint( + &mut self, + id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + _: &mut App, + ) -> Self::PrepaintState { + let id = id.expect("native webview elements always have an id"); + let frame = NativeFrame::snapped(bounds); + window.with_element_state(id, |lease: Option, _| { + let is_new = lease.is_none(); + let mut lease = lease.unwrap_or_else(|| VisibleWebView { + webview: Rc::downgrade(&self.webview), + frame: None, + }); + if lease.frame != Some(frame) { + let _ = self.webview.set_bounds(frame.wry()); + lease.frame = Some(frame); + } + if is_new { + let _ = self.webview.set_visible(true); + let _ = self.webview.focus_parent(); + } + ((), lease) + }); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + _: &mut Window, + _: &mut App, + ) { + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zeddy_plugin::manifest::ProjectAccess; + + fn request(id: u64, action: &str, fields: serde_json::Value) -> String { + let mut value = serde_json::json!({ "id": id, "action": action }); + value.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone()); + value.to_string() + } + + #[test] + fn host_filesystem_actions_use_the_instance_broker() { + let scratch = tempfile::tempdir().unwrap(); + let project = scratch.path().join("project"); + let data = scratch.path().join("data"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&data).unwrap(); + let broker = FileBroker::new(Some(project.clone()), data, ProjectAccess::ReadWrite, false); + let permissions = + Permissions { project_files: ProjectAccess::ReadWrite, ..Permissions::default() }; + let write = handle_request( + &broker, + &permissions, + None, + &request(1, "project.write", serde_json::json!({ "path": "note.txt", "data": "safe" })), + ); + assert!(write.ok, "{}", write.error); + assert_eq!(std::fs::read_to_string(project.join("note.txt")).unwrap(), "safe"); + let escape = handle_request( + &broker, + &permissions, + None, + &request(2, "project.read", serde_json::json!({ "path": "../outside" })), + ); + assert!(!escape.ok); + } + + #[test] + fn process_actions_are_manifest_gated() { + let scratch = tempfile::tempdir().unwrap(); + let data = scratch.path().join("data"); + std::fs::create_dir_all(&data).unwrap(); + let broker = FileBroker::new(None, data, ProjectAccess::None, false); + let encoded = request( + 1, + "process.run", + serde_json::json!({ "command": "printf", "args": ["hello"] }), + ); + assert!(!handle_request(&broker, &Permissions::default(), None, &encoded).ok); + let allowed = Permissions { process: true, ..Permissions::default() }; + let response = handle_request(&broker, &allowed, None, &encoded); + assert!(response.ok, "{}", response.error); + assert_eq!(response.value["stdout"], "hello"); + } +} diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs new file mode 100644 index 00000000..f17f2a25 --- /dev/null +++ b/crates/zeddy/src/workspace.rs @@ -0,0 +1,890 @@ +//! Chartr's workspace, pane, and item ownership model. +//! +//! The names and responsibilities follow Zed's `Workspace`, `PaneGroup`, and +//! `Pane`, but this module contains only Chartr's product-neutral state. GPUI +//! entities and rendering live above it. Keeping the mutations here makes the +//! invariant observable at one seam: an item belongs to exactly one pane in +//! exactly one workspace. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct PaneId(u64); + +impl PaneId { + pub fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ItemId(u64); + +impl ItemId { + pub fn get(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Axis { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Up, + Down, + Left, + Right, +} + +impl SplitDirection { + pub fn axis(self) -> Axis { + match self { + Self::Up | Self::Down => Axis::Vertical, + Self::Left | Self::Right => Axis::Horizontal, + } + } + + pub fn increasing(self) -> bool { + matches!(self, Self::Down | Self::Right) + } + + pub fn opposite(self) -> Self { + match self { + Self::Up => Self::Down, + Self::Down => Self::Up, + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } +} + +/// One leaf or split axis in a pane tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Member { + Pane { pane: PaneId }, + Axis(PaneAxis), +} + +impl Member { + fn pane(pane: PaneId) -> Self { + Self::Pane { pane } + } + + fn new_axis(old: PaneId, new: PaneId, direction: SplitDirection) -> Self { + let members = if direction.increasing() { + vec![Self::pane(old), Self::pane(new)] + } else { + vec![Self::pane(new), Self::pane(old)] + }; + Self::Axis(PaneAxis::new(direction.axis(), members)) + } + + fn first_pane(&self) -> PaneId { + match self { + Self::Pane { pane } => *pane, + Self::Axis(axis) => axis.members[0].first_pane(), + } + } + + fn collect_panes(&self, panes: &mut Vec) { + match self { + Self::Pane { pane } => panes.push(*pane), + Self::Axis(axis) => { + for member in &axis.members { + member.collect_panes(panes); + } + } + } + } + + fn contains(&self, needle: PaneId) -> bool { + match self { + Self::Pane { pane } => *pane == needle, + Self::Axis(axis) => axis.members.iter().any(|member| member.contains(needle)), + } + } + + fn collect_bounds(&self, bounds: UnitBounds, output: &mut BTreeMap) { + match self { + Self::Pane { pane } => { + output.insert(*pane, bounds); + } + Self::Axis(axis) => { + let total = axis.flexes.iter().copied().sum::().max(f32::EPSILON); + let mut offset = 0.; + for (index, member) in axis.members.iter().enumerate() { + let share = axis.flexes.get(index).copied().unwrap_or(1.) / total; + let child = match axis.axis { + Axis::Horizontal => UnitBounds { + x: bounds.x + bounds.width * offset, + y: bounds.y, + width: bounds.width * share, + height: bounds.height, + }, + Axis::Vertical => UnitBounds { + x: bounds.x, + y: bounds.y + bounds.height * offset, + width: bounds.width, + height: bounds.height * share, + }, + }; + member.collect_bounds(child, output); + offset += share; + } + } + } + } +} + +#[derive(Debug, Clone, Copy)] +struct UnitBounds { + x: f32, + y: f32, + width: f32, + height: f32, +} + +impl UnitBounds { + fn contains(self, x: f32, y: f32) -> bool { + x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height + } +} + +/// A same-axis run inside the recursive split tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneAxis { + pub axis: Axis, + pub members: Vec, + /// Relative sizes. As in Zed, inserting or removing a member resets the + /// containing axis to equal shares; direct divider resizing changes them. + pub flexes: Vec, +} + +impl PaneAxis { + fn new(axis: Axis, members: Vec) -> Self { + let flexes = vec![1.; members.len()]; + Self { axis, members, flexes } + } + + fn valid_flexes(&self) -> bool { + self.flexes.len() == self.members.len() + && self.flexes.iter().all(|flex| flex.is_finite() && *flex > 0.) + } + + fn reset_flexes(&mut self) { + self.flexes = vec![1.; self.members.len()]; + } + + fn split(&mut self, old: PaneId, new: PaneId, direction: SplitDirection) -> bool { + for (mut index, member) in self.members.iter_mut().enumerate() { + match member { + Member::Axis(axis) => { + if axis.split(old, new, direction) { + return true; + } + } + Member::Pane { pane } if *pane == old => { + if self.axis == direction.axis() { + if direction.increasing() { + index += 1; + } + self.members.insert(index, Member::pane(new)); + self.reset_flexes(); + } else { + *member = Member::new_axis(old, new, direction); + } + return true; + } + Member::Pane { .. } => {} + } + } + false + } + + /// Remove a pane and return the sole surviving child when this axis should + /// collapse into its parent. + fn remove(&mut self, target: PaneId) -> Result, ModelError> { + let mut remove_at = None; + let mut found = false; + + for (index, member) in self.members.iter_mut().enumerate() { + match member { + Member::Pane { pane } if *pane == target => { + remove_at = Some(index); + found = true; + break; + } + Member::Axis(axis) => match axis.remove(target) { + Ok(replacement) => { + if let Some(replacement) = replacement { + *member = replacement; + } + found = true; + break; + } + Err(ModelError::PaneNotFound(_)) => {} + Err(error) => return Err(error), + }, + Member::Pane { .. } => {} + } + } + + if !found { + return Err(ModelError::PaneNotFound(target)); + } + if let Some(index) = remove_at { + self.members.remove(index); + self.reset_flexes(); + } + if self.members.len() == 1 { Ok(self.members.pop()) } else { Ok(None) } + } +} + +/// One or more panes arranged in a recursive axis tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneGroup { + pub root: Member, + pub maximized: Option, +} + +impl PaneGroup { + pub fn new(root: PaneId) -> Self { + Self { root: Member::pane(root), maximized: None } + } + + pub fn panes(&self) -> Vec { + let mut panes = Vec::new(); + self.root.collect_panes(&mut panes); + panes + } + + pub fn contains(&self, pane: PaneId) -> bool { + self.root.contains(pane) + } + + /// Find the pane immediately across the requested edge. This mirrors + /// Zed's pane-group navigation: use the active pane's center on the + /// perpendicular axis and sample just beyond its bounding box. + pub fn pane_in_direction(&self, active: PaneId, direction: SplitDirection) -> Option { + let mut bounds = BTreeMap::new(); + self.root.collect_bounds(UnitBounds { x: 0., y: 0., width: 1., height: 1. }, &mut bounds); + let active_bounds = *bounds.get(&active)?; + let epsilon = 0.0001; + let center_x = active_bounds.x + active_bounds.width / 2.; + let center_y = active_bounds.y + active_bounds.height / 2.; + let (target_x, target_y) = match direction { + SplitDirection::Left => (active_bounds.x - epsilon, center_y), + SplitDirection::Right => (active_bounds.x + active_bounds.width + epsilon, center_y), + SplitDirection::Up => (center_x, active_bounds.y - epsilon), + SplitDirection::Down => (center_x, active_bounds.y + active_bounds.height + epsilon), + }; + bounds + .into_iter() + .find_map(|(pane, bounds)| bounds.contains(target_x, target_y).then_some(pane)) + } + + pub fn split(&mut self, old: PaneId, new: PaneId, direction: SplitDirection) { + let found = match &mut self.root { + Member::Pane { pane } if *pane == old => { + self.root = Member::new_axis(old, new, direction); + true + } + Member::Axis(axis) => axis.split(old, new, direction), + Member::Pane { .. } => false, + }; + + // Zed falls back to splitting the first pane when a stale caller names + // a pane that is no longer present. Preserve that convention here. + if !found { + let first = self.root.first_pane(); + match &mut self.root { + Member::Pane { .. } => self.root = Member::new_axis(first, new, direction), + Member::Axis(axis) => { + let _ = axis.split(first, new, direction); + } + } + } + } + + /// Remove a pane, retaining the sole root pane invariant. + pub fn remove(&mut self, pane: PaneId) -> Result { + match &mut self.root { + Member::Pane { pane: root } => { + if *root == pane { + Ok(false) + } else { + Err(ModelError::PaneNotFound(pane)) + } + } + Member::Axis(axis) => { + if let Some(replacement) = axis.remove(pane)? { + self.root = replacement; + } + if self.maximized == Some(pane) { + self.maximized = None; + } + Ok(true) + } + } + } + + pub fn set_flexes(&mut self, axis_path: &[usize], flexes: Vec) -> Result<(), ModelError> { + let mut member = &mut self.root; + for &index in axis_path { + member = match member { + Member::Axis(axis) => axis.members.get_mut(index).ok_or(ModelError::BadAxisPath)?, + Member::Pane { .. } => return Err(ModelError::BadAxisPath), + }; + } + let Member::Axis(axis) = member else { + return Err(ModelError::BadAxisPath); + }; + let old = std::mem::replace(&mut axis.flexes, flexes); + if !axis.valid_flexes() { + axis.flexes = old; + return Err(ModelError::InvalidFlexes); + } + Ok(()) + } + + pub fn resize_divider( + &mut self, + axis_path: &[usize], + divider: usize, + fraction: f32, + ) -> Result<(), ModelError> { + let mut member = &mut self.root; + for &index in axis_path { + member = match member { + Member::Axis(axis) => axis.members.get_mut(index).ok_or(ModelError::BadAxisPath)?, + Member::Pane { .. } => return Err(ModelError::BadAxisPath), + }; + } + let Member::Axis(axis) = member else { + return Err(ModelError::BadAxisPath); + }; + if divider + 1 >= axis.flexes.len() || !fraction.is_finite() { + return Err(ModelError::InvalidFlexes); + } + let total: f32 = axis.flexes.iter().sum(); + let before: f32 = axis.flexes[..divider].iter().sum(); + let pair = axis.flexes[divider] + axis.flexes[divider + 1]; + let minimum = pair * 0.1; + let left = (fraction.clamp(0., 1.) * total - before).clamp(minimum, pair - minimum); + axis.flexes[divider] = left; + axis.flexes[divider + 1] = pair - left; + Ok(()) + } + + pub fn toggle_maximized(&mut self, pane: PaneId) -> Result<(), ModelError> { + if !self.contains(pane) { + return Err(ModelError::PaneNotFound(pane)); + } + self.maximized = (self.maximized != Some(pane)).then_some(pane); + Ok(()) + } +} + +/// Ordered items and activation history for one pane. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Pane { + pub id: PaneId, + items: Vec, + active: Option, + activation_history: Vec, +} + +impl Pane { + fn new(id: PaneId) -> Self { + Self { id, items: Vec::new(), active: None, activation_history: Vec::new() } + } + + pub fn items(&self) -> &[ItemId] { + &self.items + } + + pub fn active(&self) -> Option { + self.active + } + + fn activate(&mut self, item: ItemId) -> Result<(), ModelError> { + if !self.items.contains(&item) { + return Err(ModelError::ItemNotFound(item)); + } + self.active = Some(item); + self.activation_history.retain(|entry| *entry != item); + self.activation_history.push(item); + Ok(()) + } + + fn insert(&mut self, item: ItemId, destination: Option) { + if let Some(old_index) = self.items.iter().position(|candidate| *candidate == item) { + self.items.remove(old_index); + } + let index = destination.unwrap_or_else(|| { + self.active + .and_then(|active| self.items.iter().position(|candidate| *candidate == active)) + .map_or(self.items.len(), |active| active + 1) + }); + self.items.insert(index.min(self.items.len()), item); + let _ = self.activate(item); + } + + fn remove(&mut self, item: ItemId) -> Result { + let index = self + .items + .iter() + .position(|candidate| *candidate == item) + .ok_or(ModelError::ItemNotFound(item))?; + self.items.remove(index); + self.activation_history.retain(|entry| *entry != item); + + if self.active == Some(item) { + self.active = self + .activation_history + .iter() + .rev() + .find(|candidate| self.items.contains(candidate)) + .copied() + .or_else(|| self.items.get(index.min(self.items.len().saturating_sub(1))).copied()); + } + Ok(index) + } +} + +/// The complete layout and ownership state of one Chartr space. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Workspace { + pub center: PaneGroup, + panes: BTreeMap, + panes_by_item: HashMap, + active_pane: PaneId, + next_pane_id: u64, + next_item_id: u64, +} + +impl Default for Workspace { + fn default() -> Self { + Self::new() + } +} + +impl Workspace { + pub fn new() -> Self { + let root = PaneId(1); + Self { + center: PaneGroup::new(root), + panes: BTreeMap::from([(root, Pane::new(root))]), + panes_by_item: HashMap::new(), + active_pane: root, + next_pane_id: 2, + next_item_id: 1, + } + } + + pub fn active_pane(&self) -> PaneId { + self.active_pane + } + + pub fn pane(&self, id: PaneId) -> Option<&Pane> { + self.panes.get(&id) + } + + pub fn panes(&self) -> impl Iterator { + self.center.panes().into_iter().filter_map(|id| self.panes.get(&id)) + } + + pub fn pane_for_item(&self, item: ItemId) -> Option { + self.panes_by_item.get(&item).copied() + } + + pub fn item_ids(&self) -> impl Iterator + '_ { + self.panes_by_item.keys().copied() + } + + pub fn activate_pane(&mut self, pane: PaneId) -> Result<(), ModelError> { + if !self.center.contains(pane) { + return Err(ModelError::PaneNotFound(pane)); + } + self.active_pane = pane; + Ok(()) + } + + pub fn activate_pane_in_direction(&mut self, direction: SplitDirection) -> Option { + let pane = self.center.pane_in_direction(self.active_pane, direction)?; + self.active_pane = pane; + Some(pane) + } + + pub fn pane_in_direction(&self, direction: SplitDirection) -> Option { + self.center.pane_in_direction(self.active_pane, direction) + } + + pub fn alloc_item(&mut self) -> ItemId { + let id = ItemId(self.next_item_id); + self.next_item_id += 1; + id + } + + pub fn add_item( + &mut self, + item: ItemId, + pane: Option, + destination: Option, + ) -> Result<(), ModelError> { + if let Some(source) = self.panes_by_item.get(&item).copied() { + if Some(source) != pane && pane.is_some() { + self.move_item(item, pane.expect("checked"), destination)?; + return Ok(()); + } + self.panes + .get_mut(&source) + .ok_or(ModelError::PaneNotFound(source))? + .insert(item, destination); + self.active_pane = source; + return Ok(()); + } + + let pane = pane.unwrap_or(self.active_pane); + self.panes.get_mut(&pane).ok_or(ModelError::PaneNotFound(pane))?.insert(item, destination); + self.panes_by_item.insert(item, pane); + self.active_pane = pane; + Ok(()) + } + + pub fn activate_item(&mut self, item: ItemId) -> Result<(), ModelError> { + let pane = self.panes_by_item.get(&item).copied().ok_or(ModelError::ItemNotFound(item))?; + self.panes.get_mut(&pane).ok_or(ModelError::PaneNotFound(pane))?.activate(item)?; + self.active_pane = pane; + Ok(()) + } + + pub fn remove_item(&mut self, item: ItemId) -> Result<(), ModelError> { + let pane = self.panes_by_item.remove(&item).ok_or(ModelError::ItemNotFound(item))?; + self.panes.get_mut(&pane).ok_or(ModelError::PaneNotFound(pane))?.remove(item)?; + Ok(()) + } + + pub fn move_item( + &mut self, + item: ItemId, + destination_pane: PaneId, + destination_index: Option, + ) -> Result<(), ModelError> { + if !self.panes.contains_key(&destination_pane) { + return Err(ModelError::PaneNotFound(destination_pane)); + } + let source = + self.panes_by_item.get(&item).copied().ok_or(ModelError::ItemNotFound(item))?; + if source == destination_pane { + self.panes.get_mut(&source).expect("known pane").insert(item, destination_index); + } else { + self.panes.get_mut(&source).expect("known pane").remove(item)?; + self.panes + .get_mut(&destination_pane) + .expect("checked pane") + .insert(item, destination_index); + self.panes_by_item.insert(item, destination_pane); + } + self.active_pane = destination_pane; + Ok(()) + } + + pub fn split_pane( + &mut self, + pane: PaneId, + direction: SplitDirection, + ) -> Result { + if !self.panes.contains_key(&pane) { + return Err(ModelError::PaneNotFound(pane)); + } + let new = PaneId(self.next_pane_id); + self.next_pane_id += 1; + self.panes.insert(new, Pane::new(new)); + self.center.split(pane, new, direction); + self.active_pane = new; + Ok(new) + } + + /// Join `source` into `destination`, moving every item in order and then + /// collapsing the recursive group. + pub fn join_pane(&mut self, source: PaneId, destination: PaneId) -> Result<(), ModelError> { + if source == destination { + return Ok(()); + } + if !self.center.contains(source) || !self.center.contains(destination) { + return Err(ModelError::PaneNotFound(if !self.center.contains(source) { + source + } else { + destination + })); + } + let items = self.panes.get(&source).ok_or(ModelError::PaneNotFound(source))?.items.clone(); + for item in items { + self.move_item(item, destination, None)?; + } + if self.center.remove(source)? { + self.panes.remove(&source); + } + self.active_pane = destination; + Ok(()) + } + + pub fn validate(&self) -> Result<(), ModelError> { + let tree_panes = self.center.panes(); + let tree_set: HashSet<_> = tree_panes.iter().copied().collect(); + let model_set: HashSet<_> = self.panes.keys().copied().collect(); + if tree_panes.len() != tree_set.len() || tree_set != model_set { + return Err(ModelError::InvalidPaneTree); + } + if !tree_set.contains(&self.active_pane) { + return Err(ModelError::PaneNotFound(self.active_pane)); + } + + let mut seen = HashSet::new(); + for (pane_id, pane) in &self.panes { + if pane.id != *pane_id { + return Err(ModelError::InvalidPaneTree); + } + if pane.active.is_some_and(|active| !pane.items.contains(&active)) { + return Err(ModelError::InvalidActiveItem(*pane_id)); + } + for item in &pane.items { + if !seen.insert(*item) { + return Err(ModelError::DuplicateItem(*item)); + } + if self.panes_by_item.get(item) != Some(pane_id) { + return Err(ModelError::ItemIndexMismatch(*item)); + } + } + } + if seen.len() != self.panes_by_item.len() { + return Err(ModelError::ItemIndexMismatch( + self.panes_by_item + .keys() + .find(|item| !seen.contains(item)) + .copied() + .unwrap_or(ItemId(0)), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelError { + PaneNotFound(PaneId), + ItemNotFound(ItemId), + DuplicateItem(ItemId), + ItemIndexMismatch(ItemId), + InvalidActiveItem(PaneId), + InvalidPaneTree, + BadAxisPath, + InvalidFlexes, +} + +impl std::fmt::Display for ModelError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ModelError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_item_has_one_owner_even_when_moved_and_readded() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + let right = workspace.split_pane(left, SplitDirection::Right).unwrap(); + let item = workspace.alloc_item(); + + workspace.add_item(item, Some(left), None).unwrap(); + workspace.add_item(item, Some(right), None).unwrap(); + workspace.add_item(item, Some(right), Some(0)).unwrap(); + + assert_eq!(workspace.pane_for_item(item), Some(right)); + assert!(!workspace.pane(left).unwrap().items().contains(&item)); + assert_eq!(workspace.pane(right).unwrap().items(), &[item]); + workspace.validate().unwrap(); + } + + #[test] + fn same_axis_splits_extend_the_axis_and_cross_axis_splits_nest() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + let right = workspace.split_pane(root, SplitDirection::Right).unwrap(); + let far_right = workspace.split_pane(right, SplitDirection::Right).unwrap(); + let below = workspace.split_pane(right, SplitDirection::Down).unwrap(); + + assert_eq!(workspace.center.panes(), vec![root, right, below, far_right]); + let Member::Axis(horizontal) = &workspace.center.root else { + panic!("horizontal root"); + }; + assert_eq!(horizontal.axis, Axis::Horizontal); + assert_eq!(horizontal.members.len(), 3); + assert!(matches!( + horizontal.members[1], + Member::Axis(PaneAxis { axis: Axis::Vertical, .. }) + )); + workspace.validate().unwrap(); + } + + #[test] + fn joining_moves_items_and_collapses_the_axis() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + let right = workspace.split_pane(left, SplitDirection::Right).unwrap(); + let first = workspace.alloc_item(); + let second = workspace.alloc_item(); + workspace.add_item(first, Some(left), None).unwrap(); + workspace.add_item(second, Some(right), None).unwrap(); + + workspace.join_pane(right, left).unwrap(); + + assert_eq!(workspace.center.panes(), vec![left]); + assert_eq!(workspace.pane(left).unwrap().items(), &[first, second]); + assert_eq!(workspace.pane_for_item(second), Some(left)); + workspace.validate().unwrap(); + } + + #[test] + fn closing_uses_activation_history_before_position() { + let mut workspace = Workspace::new(); + let pane = workspace.active_pane(); + let a = workspace.alloc_item(); + let b = workspace.alloc_item(); + let c = workspace.alloc_item(); + workspace.add_item(a, Some(pane), None).unwrap(); + workspace.add_item(b, Some(pane), None).unwrap(); + workspace.add_item(c, Some(pane), None).unwrap(); + workspace.activate_item(a).unwrap(); + workspace.activate_item(c).unwrap(); + + workspace.remove_item(c).unwrap(); + + assert_eq!(workspace.pane(pane).unwrap().active(), Some(a)); + workspace.validate().unwrap(); + } + + #[test] + fn the_root_pane_cannot_be_removed() { + let mut group = PaneGroup::new(PaneId(1)); + assert!(!group.remove(PaneId(1)).unwrap()); + assert_eq!(group.panes(), vec![PaneId(1)]); + } + + #[test] + fn persisted_layout_round_trips_with_ownership_intact() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + let down = workspace.split_pane(root, SplitDirection::Down).unwrap(); + let item = workspace.alloc_item(); + workspace.add_item(item, Some(down), None).unwrap(); + workspace.center.set_flexes(&[], vec![1.5, 0.5]).unwrap(); + workspace.center.toggle_maximized(down).unwrap(); + + let encoded = serde_json::to_string(&workspace).unwrap(); + let restored: Workspace = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(restored, workspace); + restored.validate().unwrap(); + } + + #[test] + fn invalid_flexes_do_not_replace_a_working_layout() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + workspace.split_pane(root, SplitDirection::Right).unwrap(); + assert_eq!(workspace.center.set_flexes(&[], vec![0., 2.]), Err(ModelError::InvalidFlexes)); + let Member::Axis(axis) = &workspace.center.root else { + panic!("split root"); + }; + assert_eq!(axis.flexes, vec![1., 1.]); + } + + #[test] + fn divider_resizing_changes_only_the_adjacent_pair() { + let mut workspace = Workspace::new(); + let first = workspace.active_pane(); + let second = workspace.split_pane(first, SplitDirection::Right).unwrap(); + workspace.split_pane(second, SplitDirection::Right).unwrap(); + workspace.center.set_flexes(&[], vec![1., 1., 2.]).unwrap(); + + workspace.center.resize_divider(&[], 0, 0.4).unwrap(); + + let Member::Axis(axis) = &workspace.center.root else { + panic!("axis"); + }; + assert_eq!(axis.flexes[2], 2.); + assert!((axis.flexes[0] - 1.6).abs() < 0.001); + assert!((axis.flexes[1] - 0.4).abs() < 0.001); + } + + #[test] + fn directional_focus_uses_the_center_of_nested_panes_like_zed() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + let top_right = workspace.split_pane(left, SplitDirection::Right).unwrap(); + let bottom_right = workspace.split_pane(top_right, SplitDirection::Down).unwrap(); + + workspace.activate_pane(bottom_right).unwrap(); + assert_eq!(workspace.activate_pane_in_direction(SplitDirection::Left), Some(left)); + assert_eq!(workspace.activate_pane_in_direction(SplitDirection::Right), Some(top_right)); + assert_eq!(workspace.activate_pane_in_direction(SplitDirection::Down), Some(bottom_right)); + assert_eq!(workspace.activate_pane_in_direction(SplitDirection::Down), None); + } + + #[test] + fn direction_at_an_outer_edge_has_no_neighbor() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + workspace.split_pane(left, SplitDirection::Right).unwrap(); + workspace.activate_pane(left).unwrap(); + + assert_eq!(workspace.pane_in_direction(SplitDirection::Left), None); + assert_eq!(workspace.pane_in_direction(SplitDirection::Up), None); + } + + #[test] + fn a_long_edit_sequence_preserves_tree_and_item_ownership() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + let right = workspace.split_pane(root, SplitDirection::Right).unwrap(); + let down = workspace.split_pane(right, SplitDirection::Down).unwrap(); + let items: Vec<_> = (0..12).map(|_| workspace.alloc_item()).collect(); + for (index, item) in items.iter().copied().enumerate() { + let pane = [root, right, down][index % 3]; + workspace.add_item(item, Some(pane), None).unwrap(); + workspace.validate().unwrap(); + } + for (index, item) in items.iter().copied().enumerate() { + let pane = [down, root, right][index % 3]; + workspace.move_item(item, pane, Some(0)).unwrap(); + workspace.validate().unwrap(); + } + workspace.center.resize_divider(&[], 0, 0.6).unwrap(); + workspace.join_pane(down, right).unwrap(); + for item in items.iter().step_by(2) { + workspace.remove_item(*item).unwrap(); + workspace.validate().unwrap(); + } + let unique: std::collections::HashSet<_> = workspace.item_ids().collect(); + assert_eq!(unique.len(), items.len() / 2); + let restored: Workspace = + serde_json::from_str(&serde_json::to_string(&workspace).unwrap()).unwrap(); + restored.validate().unwrap(); + } +} diff --git a/crates/zeddy/tests/live_session.rs b/crates/zeddy/tests/live_session.rs index e26c0a57..b442cf0c 100644 --- a/crates/zeddy/tests/live_session.rs +++ b/crates/zeddy/tests/live_session.rs @@ -7,24 +7,71 @@ //! //! cargo test -p zeddy --test live_session -- --ignored --nocapture -use std::time::{Duration, Instant}; +use std::{ + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; use zeddy_herdr::{Geometry, Namespace, Sidecar, control::Client}; use zeddy_vt::{Size, Terminal}; -fn client() -> Client { +struct Live { + client: Client, + child: Option, + _root: tempfile::TempDir, +} + +impl Live { + fn start() -> Self { + let root = tempfile::tempdir().expect("scratch backend root"); + let namespace = Namespace::rooted(root.path().join("chartr-zeddy/herdr")); + namespace.prepare().expect("namespace directories"); + let sidecar = sidecar(); + let mut command = Command::new(sidecar.path()); + command.arg("server").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + for (key, value) in namespace.env() { + match value { + Some(value) => command.env(key, value), + None => command.env_remove(key), + }; + } + let child = command.spawn().expect("the vendored private daemon starts"); + let client = Client::new(sidecar, namespace); + client.reconnect(Duration::from_secs(10)).expect("the private daemon answers"); + Self { client, child: Some(child), _root: root } + } + + fn crash(&mut self) { + let child = self.child.as_mut().expect("daemon child"); + child.kill().expect("kill daemon"); + child.wait().expect("reap daemon"); + self.child = None; + } +} + +impl Drop for Live { + fn drop(&mut self) { + let _ = self.client.stop_daemon(); + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn sidecar() -> Sidecar { let herdr = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../target/debug/herdr") .canonicalize() .expect("build zeddy first so herdr is vendored beside it"); - Client::new(Sidecar::at(herdr).expect("sidecar"), Namespace::private()) + Sidecar::at(herdr).expect("sidecar") } #[test] #[ignore = "needs a real herdr daemon"] fn a_shell_paints_something_within_a_few_seconds() { - let client = client(); - client.connect(Duration::from_secs(10)).expect("the private daemon comes up"); + let live = Live::start(); + let client = &live.client; let cwd = std::env::temp_dir(); let workspace = client.open_workspace(&cwd, Some("zeddy-live")).expect("a workspace"); @@ -72,3 +119,34 @@ fn a_shell_paints_something_within_a_few_seconds() { assert!(seen > 0, "no frames arrived at all"); assert!(text.contains("zeddy-live-marker"), "the echo never reached the screen"); } + +#[test] +#[ignore = "needs a real herdr daemon"] +fn a_broken_transport_recovers_without_resurrecting_dead_sessions() { + let mut live = Live::start(); + let workspace = live + .client + .open_workspace(&std::env::temp_dir(), Some("zeddy-recovery")) + .expect("workspace"); + let session = live.client.start_session(&workspace, None).expect("session"); + let attachment = live.client.attach(&session.id, Geometry::new(80, 24)).expect("attach"); + let (mut frames, mut input) = attachment.split(); + live.crash(); + + let (sent, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = sent.send(frames.next_frame()); + }); + let stream = received + .recv_timeout(Duration::from_secs(5)) + .expect("the frame stream notices daemon death"); + assert!(stream.is_err() || stream.expect("checked error").is_none()); + assert!(input.send(b"echo should-not-send\r").is_err()); + + live.client.restart().expect("one clean replacement starts"); + live.client.reconnect(Duration::from_secs(10)).expect("replacement answers"); + assert!( + live.client.sessions(None).expect("replacement session list").is_empty(), + "a dead PTY must not be presented as the old session" + ); +} diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 00000000..f736d480 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,64 @@ +# Release acceptance + +The durable behavior contract is the +[workspace specification](../.plan/maps/chartr-zeddy-workspace/spec.md). This +checklist is the release gate, not a second specification. + +## Automated gate + +Both macOS and Ubuntu must pass `.github/workflows/ci.yml`, including formatting, +the locked workspace suite, the native plugin contract build, and the Linux Wry +and GPUI X11 link. Before release, run the real-sidecar suite locally on each +shipping architecture: + +```sh +cargo test -p zeddy --test live_session -- --ignored --nocapture --test-threads=1 +``` + +That suite must paint a real shell, hard-kill Herdr, observe the broken stream, +replace the daemon, and reject the stale session identity. + +## Visual matrix + +Review at 700×900, 1100×720, and a maximized window in both Chartr Dark and +Chartr Light. Capture and compare: + +- empty Ad-hoc startup, one folder, and several spaces; +- Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; +- one pane, nested horizontal/vertical panes, resized dividers, zoom, and an + intentionally empty pane; +- terminal and plugin close buttons, active/hover/focus states, edge drop target, + grouped sidebar tabs, and the bulk-termination confirmation; +- General, Appearance, Terminal, Hotkeys, Plugins, and a contributed plugin + Settings view; +- command palette, unavailable-folder recovery, broken-stream recovery, backend + crash-loop banner, rejected plugin, and visible web permissions; +- the native Hello pane and real Clock web pane, including its persisted format. + +Reject the build for clipping, overlapping hit targets, hard-coded feature +colors, inconsistent spacing, missing close controls, duplicated tabs, a webview +that survives its pane, or any placeholder standing in for an advertised plugin +tier. + +## Interaction and accessibility + +Run the matrix with pointer and keyboard. Confirm `Cmd/Ctrl+W`, command palette, +directional focus, split-and-move, move-to-existing-pane, join, zoom, Settings +close/focus restoration, and `Ctrl+Tab` Settings-page cycling. Every drag outcome +must have a semantic action alternative. + +Inspect the GPUI accessibility tree on macOS and Linux. Tabs and Settings +navigation must expose roles, labels, and selection; Zed buttons and menus must +retain their labels and focus rings; contrast must remain readable in both +themes. Chartr introduces no animation, so reduced-motion mode requires no +alternate transition path. + +## Persistence and lifecycle + +Relaunch after changing window bounds, sidebar width/scope, mode, space names, +split ratios, active panes/items, plugin Settings, and a missing folder. Confirm +normal exit adopts detached terminals; item close kills exactly one session; +closing a populated pane or folder space confirms and kills all descendants; +session-bound plugins cascade; disabling or revoking a plugin closes every live +instance; and stale backend/plugin records are summarized without corrupting the +surviving pane tree. diff --git a/docs/adr/0001-a-private-herdr.md b/docs/adr/0001-a-private-herdr.md index df5f8ef9..f43aac60 100644 --- a/docs/adr/0001-a-private-herdr.md +++ b/docs/adr/0001-a-private-herdr.md @@ -2,10 +2,12 @@ ## Decision -zeddy runs its own herdr daemon in a namespace it owns: its own socket, XDG -directories, session name, and log, all under `~/.local/state/zeddy/herdr`. The -executable is the one vendored beside zeddy's binary, resolved by path. zeddy -never discovers, attaches to, stops, upgrades, or writes the user's own herdr. +Chartr runs its own Herdr daemon in a namespace it owns: its own socket, saved +shape, and log under `$XDG_CONFIG_HOME/chartr-zeddy/herdr` (normally +`~/.config/chartr-zeddy/herdr`). This matches the proven Chartr-rs namespace +shape. The executable is the sidecar vendored beside Chartr's binary and is +resolved by path. Chartr never discovers, attaches to, stops, upgrades, or +writes the user's own Herdr. ## Why @@ -20,10 +22,11 @@ version depend on the machine. The frame stream rides herdr's *command line*, which carries no compatibility promise, so the version is pinned exactly rather than as a floor. -`Namespace::env` clears `HERDR_SESSION`, `HERDR_PANE_ID`, and their siblings -rather than merely overriding what it sets. zeddy is frequently launched *from* -a herdr pane, and an inherited selector would otherwise point a frame stream at -a daemon the control plane is not talking to. +`Namespace::env` sets only Herdr's config root and exact socket, then clears +`HERDR_SESSION`, `HERDR_PANE_ID`, and their siblings rather than merely +overriding what it sets. Chartr is frequently launched *from* a Herdr pane, and +an inherited selector would otherwise point a frame stream at a daemon the +control plane is not talking to. ## What this rules out diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md new file mode 100644 index 00000000..57dec89b --- /dev/null +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -0,0 +1,47 @@ +# 0005 — Spaces follow Zed's multi-workspace ownership + +## Decision + +One window owns several spaces. Tabbed chrome presents the active space; +sidebar chrome can present either the active space or every space at once. The +ownership shape follows the pinned Zed revision's `workspace::MultiWorkspace`: + +- the root owns an ordered `Vec>` and the active entity; +- each `Space` owns its pane tree, items, session collection, and active item; +- the root observes every child and partitions backend snapshots between them; +- session actions carry stable pane ids, not positions in the currently drawn + list; and +- blocking backend calls run on GPUI's background executor and return owned + answers to the entity context. + +The correspondence is structural, not a dependency on Zed's `workspace` +crate. ADR 0002's dependency decision still holds: importing that crate would +also import the editor, project, collaboration, database, language, remote, and +node-runtime systems that zeddy does not use. + +## Persistence + +The folder registry lives at `$XDG_CONFIG_HOME/chartr-zeddy/spaces.toml`, with +platform fallbacks, file order as display order, duplicate suppression, and +unknown TOML keys preserved. Window bounds, chrome choice, pane trees, item +ownership, and restorable plugin state live in Chartr's SQLite state store. The +rewrite deliberately does not import or mutate older Chartr registries. + +Ad-hoc sessions are the one synthetic space. They use the operator's home +directory and have no registry row. A registered home-directory row is not +drawn beside it because herdr has one workspace per directory; two labels over +one backend workspace would pretend to be independent state when they are not. + +## Chrome + +The sketches choose where items appear: grouped vertically in sidebar mode and +horizontally for the active space in tabs mode. Both reuse Zed `ui` components +for tabs, buttons, labels, icons, colors, focus tracking, and scroll containers. +There is no custom popup, menu state machine, or parallel widget kit. + +## Consequence + +Switching spaces is an entity-selection change. It cannot reparent a session, +reuse another space's selected index, or recreate backend work. Adding a third +chrome arrangement likewise cannot change the space model: it can only draw +the active child's entries somewhere else. diff --git a/docs/adr/README.md b/docs/adr/README.md index 34fb20d9..a1349387 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,3 +9,4 @@ have to change for it to be worth revisiting. - [0002 — The Zed layer, and what it costs](0002-the-zed-layer.md) - [0003 — Two plugin tiers](0003-two-plugin-tiers.md) - [0004 — alacritty's VT core, not libghostty](0004-the-vt-core.md) +- [0005 — Spaces follow Zed's multi-workspace ownership](0005-spaces-follow-zed-multi-workspace.md) diff --git a/plugins/clock/index.html b/plugins/clock/index.html index 823df190..4744b691 100644 --- a/plugins/clock/index.html +++ b/plugins/clock/index.html @@ -29,8 +29,13 @@ --:--:-- diff --git a/plugins/clock/zeddy-plugin.toml b/plugins/clock/zeddy-plugin.toml index ddbc0902..43b8cbd4 100644 --- a/plugins/clock/zeddy-plugin.toml +++ b/plugins/clock/zeddy-plugin.toml @@ -1,6 +1,14 @@ -manifest_version = 1 +manifest_version = 2 id = "com.example.clock" name = "Clock" version = "0.1.0" kind = "web" entry = "index.html" +settings_entry = "settings.html" + +[capabilities] +multiplicity = "multiple" +restorable = true + +[permissions] +project_files = "none" diff --git a/plugins/hello/Cargo.lock b/plugins/hello/Cargo.lock index dfc0aab6..295dec0e 100644 --- a/plugins/hello/Cargo.lock +++ b/plugins/hello/Cargo.lock @@ -397,6 +397,36 @@ dependencies = [ "libloading", ] +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + [[package]] name = "collections" version = "0.1.0" @@ -449,6 +479,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -465,6 +505,43 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -472,7 +549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -485,7 +562,7 @@ dependencies = [ "bitflags 2.13.1", "block", "cfg-if", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -496,7 +573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" dependencies = [ "block", - "core-foundation", + "core-foundation 0.10.1", "core-graphics2", "io-surface", "libc", @@ -610,6 +687,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "displaydoc" version = "0.2.7" @@ -991,6 +1074,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1077,7 +1171,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", - "rand", + "rand 0.9.5", "raw-window-handle", "refineable", "regex", @@ -1103,7 +1197,8 @@ dependencies = [ "uuid", "waker-fn", "web-time", - "windows", + "windows 0.61.3", + "zed-scap", "ztracing", ] @@ -1454,7 +1549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" dependencies = [ "cgl", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "leaky-cow", ] @@ -1727,7 +1822,7 @@ checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" dependencies = [ "bitflags 2.13.1", "block", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types", "log", "objc", @@ -1810,6 +1905,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1897,6 +2001,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", ] [[package]] @@ -2012,6 +2146,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "png" version = "0.17.16" @@ -2186,6 +2326,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -2207,14 +2356,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -2224,7 +2394,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2263,8 +2442,8 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.9.5", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.20", "v_frame", @@ -2509,7 +2688,7 @@ dependencies = [ "flume", "futures", "parking_lot", - "rand", + "rand 0.9.5", "web-time", ] @@ -2545,6 +2724,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + [[package]] name = "seahash" version = "4.1.0" @@ -2964,6 +3166,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "taffy" version = "0.13.0" @@ -2976,6 +3192,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3388,6 +3616,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -3470,6 +3704,38 @@ dependencies = [ "winsafe", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + [[package]] name = "windows" version = "0.61.3" @@ -3483,6 +3749,19 @@ dependencies = [ "windows-numerics", ] +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.20", + "windows 0.61.3", + "windows-future", +] + [[package]] name = "windows-collections" version = "0.2.0" @@ -3492,14 +3771,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + [[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", @@ -3511,8 +3802,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", @@ -3529,6 +3820,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3540,6 +3842,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3573,6 +3886,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -3730,6 +4052,28 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", + "x11", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -3765,6 +4109,27 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + [[package]] name = "zeddy-plugin" version = "0.1.0" diff --git a/plugins/hello/Cargo.toml b/plugins/hello/Cargo.toml index 2b997dc8..ec43fdf5 100644 --- a/plugins/hello/Cargo.toml +++ b/plugins/hello/Cargo.toml @@ -11,6 +11,7 @@ name = "hello" version = "0.1.0" edition = "2024" +rust-version = "1.95" license = "GPL-3.0-or-later" [lib] diff --git a/plugins/hello/src/lib.rs b/plugins/hello/src/lib.rs index 8391b7fa..d5711c62 100644 --- a/plugins/hello/src/lib.rs +++ b/plugins/hello/src/lib.rs @@ -28,7 +28,13 @@ impl Plugin for Hello { registrar.add_pane("main", "Hello"); } - fn view(&mut self, _: &PaneKey, _: &mut Window, cx: &mut gpui::App) -> gpui::AnyView { + fn view( + &mut self, + _: &PaneKey, + _: &zeddy_plugin::InstanceContext, + _: &mut Window, + cx: &mut gpui::App, + ) -> gpui::AnyView { let data_dir = self.host.data_dir.display().to_string(); cx.new(|_| HelloView { data_dir }).into() } diff --git a/plugins/hello/zeddy-plugin.toml b/plugins/hello/zeddy-plugin.toml index 8696c4db..e95399f9 100644 --- a/plugins/hello/zeddy-plugin.toml +++ b/plugins/hello/zeddy-plugin.toml @@ -1,4 +1,4 @@ -manifest_version = 1 +manifest_version = 2 id = "com.example.hello" name = "Hello" version = "0.1.0" @@ -8,4 +8,8 @@ kind = "native" library = "hello" # Must equal zeddy's own. Rust and GPUI objects cross the library boundary, so # there is no compatibility range and a mismatch is refused at load. -native_abi = 1 +native_abi = 2 + +[capabilities] +multiplicity = "per_space" +restorable = true From 166e03fbbdcbd71201243ff1c356d4fe4f80d278 Mon Sep 17 00:00:00 2001 From: John Goh Date: Mon, 31 Aug 2026 23:43:06 +0800 Subject: [PATCH 003/110] Implement Zed-style pane interactions --- .plan/maps/chartr-zeddy-workspace/spec.md | 49 ++- CHARTR.md | 3 - README.md | 22 +- crates/zeddy/src/app.rs | 459 +++++++++++++--------- crates/zeddy/src/chrome.rs | 57 ++- crates/zeddy/src/chrome/sidebar.rs | 141 ++----- crates/zeddy/src/chrome/tabs.rs | 49 ++- crates/zeddy/src/settings.rs | 4 - crates/zeddy/src/space.rs | 62 ++- crates/zeddy/src/web_plugin.rs | 60 ++- crates/zeddy/src/workspace.rs | 258 +++++++++++- docs/acceptance.md | 27 +- 12 files changed, 829 insertions(+), 362 deletions(-) diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index 68fcc284..8886249b 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -34,9 +34,10 @@ clone support. Cross-space movement is not supported. Offer tabbed and sidebar projections over the same model. Tabbed mode shows one active space and local tab bars for each pane. Sidebar mode can show all spaces or -only the active space, visually groups tabs by their pane tree, and uses compact -pane headers and drop targets instead of duplicate local tab labels. Presentation -never changes item ownership. +only the active space and collapses a multi-pane group to one tab labelled by its +last-active item. Every non-empty workspace pane keeps a visible, draggable tab +bar; only the active pane exposes compact split/zoom controls. Presentation never +changes item ownership. Use Zed's existing GPUI, UI, and theme crates and their components, semantic colors, spacing, typography, focus, accessibility, menu, modal, notification, @@ -80,7 +81,7 @@ configuration automatically. 17. As a Chartr user, I want to drag a tab between panes, so that I can reorganize the current space without recreating its item. 18. As a Chartr user, I want to drop a tab on a pane edge to create a split, so that advanced layouts are direct and discoverable. 19. As a Chartr user, I want joining a pane to move its items into an adjacent pane, so that changing layout never kills work. -20. As a Chartr user, I want empty panes retained until I explicitly join them, so that deliberate drop targets do not disappear. +20. As a Chartr user, I want a split pane removed when its last item leaves, following Zed's default pane lifecycle, so that empty implementation structure does not accumulate in the UI. 21. As a Chartr user, I want at least one root pane to remain, so that an empty space is still usable. 22. As a Chartr user, I want to zoom or maximize a pane, so that I can temporarily concentrate on one item. 23. As a Chartr user, I want terminals never to be cloned or mirrored, so that one session is never represented by multiple terminal tabs. @@ -88,11 +89,11 @@ configuration automatically. 25. As a Chartr user, I want pane layouts and split ratios restored after switching spaces, so that every space behaves like an independent editor window. 26. As a Chartr user, I want pane layouts and active items restored after relaunch, so that restarting Chartr does not destroy organization. 27. As a Chartr user, I want tabbed mode to show only the active space, so that its compact chrome remains focused. -28. As a Chartr user, I want each pane in tabbed mode to have its own tab bar, so that tab ownership is visible. +28. As a Chartr user, I want every non-empty workspace pane in either presentation mode to retain its own draggable tab bar, so that tab ownership and movement remain visible like Zed. 29. As a Chartr user, I want sidebar mode to show either all spaces or only the active space, so that I can choose overview or focus. 30. As a Chartr user, I want All Spaces to be the initial sidebar mode, so that a fresh installation exposes the whole cockpit. -31. As a Chartr user, I want pane-owned tabs visually grouped in the sidebar, so that split membership remains clear without duplicate pane tab bars. -32. As a Chartr user, I want compact pane headers and drop targets in sidebar mode, so that advanced pane operations remain available. +31. As a Chartr user, I want a multi-pane group collapsed to one sidebar tab labelled by its last-active item, so that the sidebar represents the grouped workspace rather than every pane implementation detail. +32. As a Chartr user, I want only the active non-empty pane to expose compact Zed-style split and zoom controls while all pane tab bars remain visible, so that advanced operations remain available without hiding the pane structure. 33. As a Chartr user, I want selecting an item in an inactive space to activate its space, pane, and item together, so that selection is one coherent action. 34. As a Chartr user, I want the sidebar width and presentation modes persisted, so that the application retains my preferred chrome. 35. As a Chartr user, I want the top-level visual pane group to be closable, so that I can end everything beneath it deliberately. @@ -171,11 +172,28 @@ configuration automatically. removes it from its source pane before insertion. Cross-space moves are absent. - Pane mutations use typed actions and pane events. Product chrome does not reach into pane internals to mutate vectors directly. -- Dragged tabs carry their source pane and item identity. Drops reorder within a - pane, move between panes, or split at pane edges. Modifier cloning is available - only to plugin items that declare it. -- Joining a pane moves items and collapses the axis. Closing the last item retains - an empty pane; at least the root pane always remains. +- Dragged tabs carry their source pane, source index, and item identity, and use + the same tab component for their drag preview. Drops on tabs use Zed's + source-aware before/after insertion rule; the trailing tab-strip target + appends; pane-body center drops move into the target pane; and pane-body edge + drops split it. Modifier cloning is available only to plugin items that + declare it, with non-cloneable items falling back to an ordinary move. +- Pane split hit-testing exists only over pane content, never over its tab bar. + Its edge band is 20% of the shorter pane dimension, corners resolve to the + nearest edge, and the remainder is the center target. The transient highlight + fills the content for center drops and the relevant half for edge drops. Tab + and trailing-strip targets clear split intent; `Escape` cancels the drag and + clears any transient target. +- A web-plugin child view reports pointer focus through the private host bridge + so its owning item and pane become active just like native GPUI content. + Native child webviews are hidden only for the duration of a GPUI drag so the + dragged tab and pane drop highlight remain visible above their pixels. +- Joining a pane moves items and collapses the axis. Moving or closing the last + item collapses a non-root pane; the sole root pane remains as the empty + workspace's open/drop target. As in Zed, invoking split-and-move on a pane + with only one item instead inserts an empty pane on the opposite side and + keeps the item focused, so the requested split is visible rather than being + immediately collapsed by the ordinary empty-source rule. - The visual sidebar group is not an item. Its close control is a bulk lifecycle action over all descendant items. Only top-level space groups expose that bulk control; panes expose their own Close All action. @@ -189,6 +207,9 @@ configuration automatically. do not participate in identity. - Tabbed and sidebar modes are alternate renderings of the same space/pane/item state. Changing chrome never creates, moves, or closes an item. +- A multi-pane group projects to one sidebar tab labelled by its last-active + item. Closing that tab closes every item in the group through the normal bulk + lifecycle confirmation. - Sidebar mode persists an All Spaces or Active Space submode. Selecting an item from another space activates its space, pane, and item as one operation. - The sidebar is resizable with bounded width. Tabbed mode is active-space-only. @@ -252,8 +273,8 @@ configuration automatically. - A small window-owned health state machine checks the private daemon, performs one clean replacement, and detects a second failure within 60 seconds as a crash loop. It exposes Retry and no backend administration UI. -- Backend loss removes terminal items and their session-bound plugins but retains - spaces, pane geometry, deliberately empty panes, and space-bound plugin items. +- Backend loss removes terminal items and their session-bound plugins, collapses + newly empty splits, and retains spaces plus space-bound plugin items. - Herdr is authoritative for live session existence. Orphaned sessions enter the owning space's last-active pane; stale saved terminal items are dropped. - Versioned SQLite persistence stores space identities, pane trees, item records, diff --git a/CHARTR.md b/CHARTR.md index 3f2f3655..be235ab2 100644 --- a/CHARTR.md +++ b/CHARTR.md @@ -13,8 +13,5 @@ A file under `.plan/maps/` is read by chartr only where it follows the format st The skills chartr can resolve, in the order it resolves them. - `chartr-skills` at `.chartr/skills/chartr-skills` — grill, implement, prototype, research, to-spec, to-tickets, wayfinder -- `matt-pocock` at `.chartr/skills/matt-pocock` — ask-matt, code-review, codebase-design, diagnosing-bugs, domain-modeling, grill-with-docs, implement, improve-codebase-architecture, prototype, research, resolving-merge-conflicts, setup-matt-pocock-skills, tdd, to-spec, to-tickets, triage, wayfinder, wizard, claude-handoff, loop-me, setup-ts-deep-modules, writing-beats, writing-fragments, writing-shape, git-guardrails-claude-code, migrate-to-shoehorn, scaffold-exercises, setup-pre-commit, grill-me, grilling, handoff, teach, to-questionnaire, wait-what, writing-for-agents -- `impeccable` at `.chartr/skills/impeccable` — impeccable -- `emil-kowalski` at `.chartr/skills/emil-kowalski` — animate, animation-vocabulary, apple-design, ask-sonner, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations Where two of them carry a skill of the same name, the earlier one is what a bare name reaches, and the later one is reached as `source/skill`. diff --git a/README.md b/README.md index d1c10d74..21a21203 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,23 @@ its Herdr session. A plugin may opt into multiple instances, modifier cloning, restoration, and explicit binding to one terminal session. Panes support nested horizontal and vertical splits, divider resizing, -directional focus, joining, zooming, tab reordering, movement, and edge-drop -splitting. The command palette provides keyboard alternatives for pane -operations. `Cmd+W` on macOS and `Ctrl+W` on Linux closes the active item; -operations that terminate multiple live sessions confirm with an exact count. +directional focus, joining, zooming, and Zed-style tab dragging. Tab and +trailing-strip drops reorder or move items; pane-body center drops move into a +pane; the four edge targets split it, with Zed's transient full/half-pane +highlight. Escape cancels a drag. The command palette provides keyboard +alternatives for pane operations. `Cmd+W` on macOS and `Ctrl+W` on Linux closes +the active item; operations that terminate multiple live sessions confirm with +an exact count. As in Zed, a non-root pane disappears when its last item leaves; +one empty root remains so a space always has an open and drop target. Splitting +a lone-tab pane uses Zed's opposite-empty-pane rule, keeping the tab focused and +leaving the requested side available as a drop target. Sidebar and tabbed modes are projections over that same model. Sidebar mode can -show all spaces or only the active space and groups each pane's items. Tabbed -mode shows one space and uses Zed tabs, including close controls for plugin -items. Switching presentation never reparents or recreates an item. +show all spaces or only the active space. A multi-pane group collapses to one +sidebar tab labelled by its last-active item, while every pane in the workspace +keeps its Zed-style draggable tab bar. Tabbed mode shows one space and uses the +same pane tabs, including close controls for plugin items. Switching presentation +never reparents or recreates an item. ## Settings and persistence diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index c02d602b..89d6b50b 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -751,6 +751,7 @@ impl Zeddy { SpaceEntries { id: space.entity_id(), name: read.name().to_owned(), + active: self.active.as_ref() == Some(space), removable: read.kind() == SpaceKind::Registered, available: read.available(), panes: read.pane_entries(space.entity_id()), @@ -785,7 +786,15 @@ impl Zeddy { target.update(cx, |space, cx| space.start_session(cx)); } } - Action::ClosePane { space, pane } => self.request_close_pane(space, pane, window, cx), + Action::CloseGroup { space } => { + let Some(space) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + else { + return; + }; + let ids = space.read(cx).all_item_ids(); + self.request_bulk_close(space, ids, false, window, cx); + } Action::CloseSpace { space } => self.request_close_space(space, window, cx), Action::RenameSpace { space } => { if let Some(target) = @@ -797,27 +806,24 @@ impl Zeddy { } } Action::LocateSpace { space } => self.locate_space(space, cx), - action @ Action::MoveToPane { space, item, target: target_pane, .. } => { - if let Some(target_space) = + Action::MoveItem { space, item, source, source_index, target, target_index } => { + let Some(target_space) = self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() - { - let clone = cfg!(target_os = "macos") && window.modifiers().alt - || cfg!(not(target_os = "macos")) && window.modifiers().control; - if clone - && self.clone_plugin_drop( - target_space.clone(), - item, - target_pane, - None, - window, - cx, - ) - { - cx.notify(); - return; - } - target_space.update(cx, |space, cx| space.act(action, cx)); + else { + return; + }; + if self.active.as_ref() != Some(&target_space) { + self.activate(target_space.clone(), window, cx); } + let dragged = DraggedItem { + space: target_space.read(cx).key(), + pane: source, + index: source_index, + item, + title: String::new(), + selected: false, + }; + self.handle_item_drop(&dragged, target, target_index, false, window, cx); } action @ (Action::Select { .. } | Action::Close { .. }) => { let target = match &action { @@ -861,27 +867,17 @@ impl Zeddy { } } - fn request_close_pane( - &mut self, - space_id: EntityId, - pane: LayoutPaneId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(space) = self.spaces.iter().find(|space| space.entity_id() == space_id).cloned() - else { - return; - }; - let ids = space.read(cx).pane_item_ids(pane); - self.request_bulk_close(space, ids, false, window, cx); - } - fn request_close_active_pane(&mut self, window: &mut Window, cx: &mut Context) { let Some(space) = self.active.clone() else { return; }; let pane = space.read(cx).layout().active_pane(); let ids = space.read(cx).pane_item_ids(pane); + if ids.is_empty() { + space.update(cx, |space, _| space.remove_empty_pane(pane)); + cx.notify(); + return; + } self.request_bulk_close(space, ids, false, window, cx); } @@ -1228,6 +1224,7 @@ impl Zeddy { ), permissions, None, + None, window, cx, ), @@ -1342,6 +1339,18 @@ impl Zeddy { } } + fn split_and_move_in( + &mut self, + pane: LayoutPaneId, + direction: SplitDirection, + cx: &mut Context, + ) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.split_and_move_in(pane, direction)); + cx.notify(); + } + } + fn move_active_to_pane(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(space) = self.active.clone() { space.update(cx, |space, _| space.move_active_to_pane(direction)); @@ -1525,6 +1534,16 @@ impl Zeddy { cx.notify(); return; } + if event.keystroke.key == "escape" && cx.stop_active_drag(window) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| { + space.clear_drag_target(); + }); + } + cx.stop_propagation(); + cx.notify(); + return; + } if self.settings_open { if event.keystroke.modifiers.control && event.keystroke.key == "tab" { cx.stop_propagation(); @@ -1644,6 +1663,22 @@ impl Zeddy { .into_any_element() } + fn web_plugin_focus_handler( + space: Entity, + cx: &Context, + ) -> crate::web_plugin::FocusHandler { + let weak = cx.weak_entity(); + Rc::new(move |view, cx| { + let space = space.clone(); + let _ = weak.update(cx, |this, cx| { + if space.update(cx, |space, _| space.activate_plugin_view(view)) { + this.active = Some(space); + cx.notify(); + } + }); + }) + } + fn open_plugin( &mut self, key: zeddy_plugin::PaneKey, @@ -1695,6 +1730,7 @@ impl Zeddy { }; let session_access = bound_session.as_ref().and_then(|session| space.read(cx).session_access(session)); + let on_focus = Some(Self::web_plugin_focus_handler(space.clone(), cx)); let unsafe_filesystem = self.settings.resolved().plugin(&key.plugin).unsafe_filesystem; let Some(plugin) = self.catalog.get_mut(&key.plugin) else { self.problem = Some("That plugin is no longer loaded.".to_owned()); @@ -1715,6 +1751,7 @@ impl Zeddy { broker, permissions.clone(), session_access, + on_focus, window, cx, ) @@ -1784,6 +1821,7 @@ impl Zeddy { let bound = bound_session.clone().map(zeddy_herdr::PaneId); let session_access = bound.as_ref().and_then(|session| space.read(cx).session_access(session)); + let on_focus = Some(Self::web_plugin_focus_handler(space.clone(), cx)); if bound.is_some() && session_access.is_none() { failures.push(format!("{plugin}:{pane} lost its bound session")); continue; @@ -1811,6 +1849,7 @@ impl Zeddy { broker, permissions.clone(), session_access, + on_focus, window, cx, ) @@ -1866,6 +1905,7 @@ impl Zeddy { }; let session_access = bound_session.as_ref().and_then(|session| space.read(cx).session_access(session)); + let on_focus = Some(Self::web_plugin_focus_handler(space.clone(), cx)); let unsafe_filesystem = self.settings.resolved().plugin(&key.plugin).unsafe_filesystem; let Some(destination) = space.update(cx, |space, _| space.prepare_drop_destination(target)) else { @@ -1886,6 +1926,7 @@ impl Zeddy { ), permissions, session_access, + on_focus, window, cx, ), @@ -1909,6 +1950,45 @@ impl Zeddy { true } + /// Zed has one pane drop path shared by tab targets and the pane body. + /// Body drops may consume the current edge split direction; tab-bar drops + /// explicitly clear it and only reorder or move into the target pane. + fn handle_item_drop( + &mut self, + dragged: &DraggedItem, + target: LayoutPaneId, + index: usize, + allow_split: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(space) = self.active.clone() else { + return; + }; + if space.read(cx).persisted().key != dragged.space { + space.update(cx, |space, _| { + space.clear_drag_target(); + }); + cx.notify(); + return; + } + if !allow_split { + space.update(cx, |space, _| space.set_drag_target(target, None)); + } + let clone = cfg!(target_os = "macos") && window.modifiers().alt + || cfg!(not(target_os = "macos")) && window.modifiers().control; + if clone + && self.clone_plugin_drop(space.clone(), dragged.item, target, Some(index), window, cx) + { + cx.notify(); + return; + } + space.update(cx, |space, _| { + space.drop_item(dragged.item, dragged.pane, target, Some(index)); + }); + cx.notify(); + } + fn workspace_pane(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { let Some(space) = self.active.clone() else { return message("No space. Add a folder to begin.", cx).into_any_element(); @@ -2067,6 +2147,7 @@ impl Zeddy { PaneAxisDirection::Horizontal => h_flex() .id(format!("pane-axis-h-{current_path:?}")) .size_full() + .items_stretch() .min_w_0() .min_h_0() .gap_px() @@ -2147,8 +2228,8 @@ impl Zeddy { return message("Pane layout is unavailable.", cx).into_any_element(); }; let active_pane = space.layout().active_pane() == pane_id; - let header = - if show_header { Some(self.pane_header(space, pane_id, on, weak, cx)) } else { None }; + let header = (show_header && pane.active().is_some()) + .then(|| self.pane_header(space, pane_id, on, weak, cx)); let content = pane .active() .and_then(|id| space.item(id).map(|item| (id, item))) @@ -2222,7 +2303,18 @@ impl Zeddy { let drag_move = weak.clone(); let drop_item = weak.clone(); - let drop_overlay = space.drag_target().filter(|(pane, _)| *pane == pane_id); + let focus_pane = weak.clone(); + let drop_group = format!("pane-drop-{}", pane_id.get()); + let drop_space = space.persisted().key; + let drag_space = drop_space.clone(); + let pane_drop_index = pane + .active() + .and_then(|active| pane.items().iter().position(|item| *item == active)) + .unwrap_or(pane.items().len()); + let drop_direction = space + .drag_target() + .filter(|(pane, _)| *pane == pane_id) + .and_then(|(_, direction)| direction); v_flex() .id(("pane", pane_id.get() as usize)) .relative() @@ -2233,48 +2325,58 @@ impl Zeddy { .when(active_pane, |pane| { pane.border_1().border_color(cx.theme().colors().pane_focused_border) }) - .on_drag_move::(move |event, _, cx| { - let direction = split_direction_for_drag(event); - let _ = drag_move.update(cx, |this, cx| { + .capture_any_mouse_down(move |_, window, cx| { + let _ = focus_pane.update(cx, |this, cx| { if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.set_drag_target(pane_id, direction)); + space.update(cx, |space, _| space.activate_pane(pane_id)); } - cx.notify(); - }); - }) - .on_drop(move |dragged: &DraggedItem, window, cx| { - let dragged = dragged.clone(); - let _ = drop_item.update(cx, |this, cx| { - let Some(space) = this.active.clone() else { - return; - }; - if space.read(cx).persisted().key != dragged.space { - return; - } - let clone = cfg!(target_os = "macos") && window.modifiers().alt - || cfg!(not(target_os = "macos")) && window.modifiers().control; - if clone - && this.clone_plugin_drop( - space.clone(), - dragged.item, - pane_id, - None, - window, - cx, - ) - { - cx.notify(); - return; - } - space.update(cx, |space, _| { - space.drop_item(dragged.item, dragged.pane, pane_id, None) - }); + window.focus(&this.focus, cx); cx.notify(); }); }) .children(header) - .child(div().flex_1().min_h_0().min_w_0().child(content)) - .when_some(drop_overlay, |pane, (_, direction)| pane.child(drop_target(direction, cx))) + .child( + div() + .flex_1() + .relative() + .min_h_0() + .min_w_0() + .group(drop_group.clone()) + .on_drag_move::(move |event, _, cx| { + let accepted = event.drag(cx).space == drag_space; + let direction = accepted.then(|| split_direction_for_drag(event)).flatten(); + let _ = drag_move.update(cx, |this, cx| { + let changed = this.active.clone().is_some_and(|space| { + space.update(cx, |space, _| { + if accepted { + space.set_drag_target(pane_id, direction) + } else { + space.clear_drag_target() + } + }) + }); + if changed { + cx.notify(); + } + }); + }) + .child(content) + .child(drop_target(drop_direction, drop_group, drop_space, cx).on_drop( + move |dragged: &DraggedItem, window, cx| { + let dragged = dragged.clone(); + let _ = drop_item.update(cx, |this, cx| { + this.handle_item_drop( + &dragged, + pane_id, + pane_drop_index, + true, + window, + cx, + ); + }); + }, + )), + ) .into_any_element() } @@ -2289,52 +2391,6 @@ impl Zeddy { let Some(pane) = space.layout().pane(pane_id) else { return div().into_any_element(); }; - let focus_pane = weak.clone(); - if self.mode == Mode::Sidebar { - return h_flex() - .id(("sidebar-pane-header", pane_id.get())) - .role(Role::Group) - .aria_label(format!("Pane {}", pane_id.get())) - .group("pane-header") - .h(Tab::container_height(cx)) - .px_2() - .justify_between() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .on_click(move |_, _, cx| { - let _ = focus_pane.update(cx, |this, cx| { - if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.activate_pane(pane_id)); - } - cx.notify(); - }); - }) - .child( - Label::new(format!("Pane {}", pane_id.get())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - h_flex() - .gap_1() - .child( - Label::new(format!( - "{} tab{}", - pane.items().len(), - if pane.items().len() == 1 { "" } else { "s" } - )) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .visible_on_hover("pane-header") - .child(pane_controls(weak, pane_id)), - ), - ) - .into_any_element(); - } let active_index = pane.active().and_then(|active| pane.items().iter().position(|item| *item == active)); @@ -2354,12 +2410,14 @@ impl Zeddy { let select_item = on.clone(); let close_item = on.clone(); let drop_item = weak.clone(); + let drop_space = space_key.clone(); let dragged = DraggedItem { space: space_key.clone(), - space_entity: None, pane: pane_id, + index, item: *id, title: item.title(), + selected, }; Some( Tab::new(format!("pane-{}-item-{}", pane_id.get(), id.get())) @@ -2372,35 +2430,27 @@ impl Zeddy { select_item(Action::Select { space: None, item: select }, window, cx) }) .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| dragged.space == drop_space) + }) + .drag_over::(move |tab, dragged, _, cx| { + let mut tab = tab + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + if index < dragged.index { + tab = tab.border_l_2(); + } else if index > dragged.index { + tab = tab.border_r_2(); + } + tab + }) .on_drop(move |dragged: &DraggedItem, window, cx| { let dragged = dragged.clone(); let _ = drop_item.update(cx, |this, cx| { - let Some(space) = this.active.clone() else { - return; - }; - if space.read(cx).persisted().key != dragged.space { - return; - } - let clone = cfg!(target_os = "macos") && window.modifiers().alt - || cfg!(not(target_os = "macos")) && window.modifiers().control; - if clone - && this.clone_plugin_drop( - space.clone(), - dragged.item, - pane_id, - Some(index), - window, - cx, - ) - { - cx.notify(); - return; - } - space.update(cx, |space, _| { - space.set_drag_target(pane_id, None); - space.drop_item(dragged.item, dragged.pane, pane_id, Some(index)); - }); - cx.notify(); + this.handle_item_drop(&dragged, pane_id, index, false, window, cx); }); }) .end_slot( @@ -2419,8 +2469,32 @@ impl Zeddy { .into_any_element(), ) }); + let append_drop = weak.clone(); + let append_index = pane.items().len(); + let append_space = space_key.clone(); + let tab_bar_drop_target = div() + .id(format!("pane-{}-tab-bar-drop-target", pane_id.get())) + .min_w_6() + .h(Tab::container_height(cx)) + .flex_grow_1() + .child("") + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| dragged.space == append_space) + }) + .drag_over::(|bar, _, _, cx| { + bar.bg(cx.theme().colors().drop_target_background) + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + let dragged = dragged.clone(); + let _ = append_drop.update(cx, |this, cx| { + this.handle_item_drop(&dragged, pane_id, append_index, false, window, cx); + }); + }); TabBar::new(format!("pane-{}-tabs", pane_id.get())) .children(tabs) + .child(tab_bar_drop_target) .end_child(pane_controls(weak, pane_id)) .into_any_element() } @@ -3379,16 +3453,27 @@ fn setting_label(label: &'static str) -> AnyElement { fn split_direction_for_drag(event: &DragMoveEvent) -> Option { let bounds = event.bounds; - let size = bounds.size.width.min(bounds.size.height) * 0.25; let x = event.event.position.x - bounds.left(); let y = event.event.position.y - bounds.top(); - if x >= size && x <= bounds.size.width - size && y >= size && y <= bounds.size.height - size { + split_direction_for_position( + bounds.size.width.into(), + bounds.size.height.into(), + x.into(), + y.into(), + ) +} + +/// Zed's pane-body hit test. The edge band is 20% of the pane's shorter side; +/// corners resolve to the nearest edge in Up, Right, Down, Left tie order. +fn split_direction_for_position(width: f32, height: f32, x: f32, y: f32) -> Option { + let size = width.min(height) * 0.2; + if x >= size && x <= width - size && y >= size && y <= height - size { return None; } [ (SplitDirection::Up, y), - (SplitDirection::Right, bounds.size.width - x), - (SplitDirection::Down, bounds.size.height - y), + (SplitDirection::Right, width - x), + (SplitDirection::Down, height - y), (SplitDirection::Left, x), ] .into_iter() @@ -3396,12 +3481,15 @@ fn split_direction_for_drag(event: &DragMoveEvent) -> Option, cx: &App) -> Div { +fn drop_target(direction: Option, group: String, space: String, cx: &App) -> Div { div() + .invisible() .absolute() - .border_2() - .border_color(cx.theme().colors().drop_target_border) .bg(cx.theme().colors().drop_target_background) + .can_drop(move |value, _, _| { + value.downcast_ref::().is_some_and(|dragged| dragged.space == space) + }) + .group_drag_over::(group, |style| style.visible()) .map(|target| match direction { None => target.top_0().right_0().bottom_0().left_0(), Some(SplitDirection::Up) => target.top_0().left_0().right_0().h(relative(0.5)), @@ -3432,9 +3520,7 @@ fn pane_resize_handle(dragged: DraggedPaneDivider, axis: PaneAxisDirection) -> i fn pane_controls(weak: &gpui::WeakEntity, pane_id: LayoutPaneId) -> AnyElement { let focus = weak.clone(); let split = weak.clone(); - let join = weak.clone(); let zoom = weak.clone(); - let close_all = weak.clone(); h_flex() .id(("pane-controls", pane_id.get())) @@ -3464,34 +3550,27 @@ fn pane_controls(weak: &gpui::WeakEntity, pane_id: LayoutPaneId) -> AnyEl let down = split.clone(); menu.entry("Split Left", None, move |_, cx| { let _ = left.update(cx, |this, cx| { - this.split_and_move(SplitDirection::Left, cx) + this.split_and_move_in(pane_id, SplitDirection::Left, cx) }); }) .entry("Split Right", None, move |_, cx| { let _ = right.update(cx, |this, cx| { - this.split_and_move(SplitDirection::Right, cx) + this.split_and_move_in(pane_id, SplitDirection::Right, cx) }); }) .entry("Split Up", None, move |_, cx| { - let _ = up - .update(cx, |this, cx| this.split_and_move(SplitDirection::Up, cx)); + let _ = up.update(cx, |this, cx| { + this.split_and_move_in(pane_id, SplitDirection::Up, cx) + }); }) .entry("Split Down", None, move |_, cx| { let _ = down.update(cx, |this, cx| { - this.split_and_move(SplitDirection::Down, cx) + this.split_and_move_in(pane_id, SplitDirection::Down, cx) }); }) })) }), ) - .child( - IconButton::new(("pane-join", pane_id.get()), IconName::ListCollapse) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Join Pane Into Next")) - .on_click(move |_, _, cx| { - let _ = join.update(cx, |this, cx| this.join_active_into_next(cx)); - }), - ) .child( IconButton::new(("pane-zoom", pane_id.get()), IconName::Maximize) .icon_size(IconSize::XSmall) @@ -3500,15 +3579,6 @@ fn pane_controls(weak: &gpui::WeakEntity, pane_id: LayoutPaneId) -> AnyEl let _ = zoom.update(cx, |this, cx| this.toggle_zoom(cx)); }), ) - .child( - IconButton::new(("pane-close-all", pane_id.get()), IconName::Close) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Close All in Pane")) - .on_click(move |_, window, cx| { - let _ = - close_all.update(cx, |this, cx| this.request_close_active_pane(window, cx)); - }), - ) .into_any_element() } @@ -3591,3 +3661,36 @@ fn plugin_paths() -> Paths { }); Paths::under(root.join("chartr-zeddy")) } + +#[cfg(test)] +mod pane_drop_tests { + use super::{SplitDirection, split_direction_for_position}; + + #[test] + fn zed_drop_zone_has_a_center_and_four_edge_bands() { + assert_eq!(split_direction_for_position(100., 100., 50., 50.), None); + assert_eq!(split_direction_for_position(100., 100., 19.9, 50.), Some(SplitDirection::Left)); + assert_eq!( + split_direction_for_position(100., 100., 80.1, 50.), + Some(SplitDirection::Right) + ); + assert_eq!(split_direction_for_position(100., 100., 50., 19.9), Some(SplitDirection::Up)); + assert_eq!(split_direction_for_position(100., 100., 50., 80.1), Some(SplitDirection::Down)); + } + + #[test] + fn zed_drop_zone_uses_the_shorter_side_and_excludes_the_boundary() { + assert_eq!(split_direction_for_position(400., 100., 20., 50.), None); + assert_eq!(split_direction_for_position(400., 100., 19.9, 50.), Some(SplitDirection::Left)); + assert_eq!(split_direction_for_position(400., 100., 200., 20.), None); + assert_eq!(split_direction_for_position(400., 100., 200., 19.9), Some(SplitDirection::Up)); + } + + #[test] + fn zed_drop_zone_resolves_corners_to_the_nearest_edge() { + assert_eq!(split_direction_for_position(100., 100., 5., 5.), Some(SplitDirection::Up)); + assert_eq!(split_direction_for_position(100., 100., 96., 8.), Some(SplitDirection::Right)); + assert_eq!(split_direction_for_position(100., 100., 92., 97.), Some(SplitDirection::Down)); + assert_eq!(split_direction_for_position(100., 100., 3., 90.), Some(SplitDirection::Left)); + } +} diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index d37dfb00..490a3ba8 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -12,7 +12,7 @@ use std::rc::Rc; use crate::workspace::{ItemId, PaneId}; use gpui::EntityId; -use ui::prelude::*; +use ui::{Tab, prelude::*}; /// One row in the sidebar, or one tab in the strip. #[derive(Debug, Clone, PartialEq, Eq)] @@ -21,6 +21,7 @@ pub struct Entry { pub space_key: String, pub key: ItemId, pub pane: PaneId, + pub index: usize, pub title: String, /// The agent herdr believes is running, when it knows one. In sidebar mode /// this is a second line; in tabs mode there is no room and it is dropped. @@ -36,6 +37,7 @@ pub struct Entry { pub struct SpaceEntries { pub id: EntityId, pub name: String, + pub active: bool, pub removable: bool, pub available: bool, pub panes: Vec, @@ -51,14 +53,37 @@ pub struct PaneEntries { /// What the user did to the chrome. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { - Select { space: Option, item: ItemId }, - Close { space: Option, item: ItemId }, - ClosePane { space: EntityId, pane: PaneId }, - CloseSpace { space: EntityId }, - RenameSpace { space: EntityId }, - LocateSpace { space: EntityId }, - NewInSpace { space: EntityId }, - MoveToPane { space: EntityId, item: ItemId, source: PaneId, target: PaneId }, + Select { + space: Option, + item: ItemId, + }, + Close { + space: Option, + item: ItemId, + }, + MoveItem { + space: EntityId, + item: ItemId, + source: PaneId, + source_index: usize, + target: PaneId, + target_index: usize, + }, + CloseGroup { + space: EntityId, + }, + CloseSpace { + space: EntityId, + }, + RenameSpace { + space: EntityId, + }, + LocateSpace { + space: EntityId, + }, + NewInSpace { + space: EntityId, + }, New, ToggleMode, ToggleSidebarScope, @@ -82,21 +107,17 @@ impl Render for DraggedSidebar { #[derive(Clone)] pub struct DraggedItem { pub space: String, - pub space_entity: Option, pub pane: PaneId, + pub index: usize, pub item: ItemId, pub title: String, + pub selected: bool, } impl Render for DraggedItem { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .px_3() - .py_1() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Tab::new(("dragged-item", self.item.get() as usize)) + .toggle_state(self.selected) .child(Label::new(self.title.clone()).size(LabelSize::Small)) } } diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index b18fc3da..8caa7db2 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -98,106 +98,25 @@ pub fn render( ) .into_any_element(), ); - if space.panes.is_empty() { - groups.push( - div() - .px_2() - .py_1() - .child(Label::new("No open tabs").size(LabelSize::XSmall).color(Color::Muted)) - .into_any_element(), - ); - continue; - } - for pane in &space.panes { - let count = pane.entries.len(); - let close = on.clone(); - let move_item = on.clone(); - let close_space = space.id; - let move_space = space.id; - let pane_id = pane.id; - groups.push( - h_flex() - .id(format!("sidebar-pane-drop-{}-{}", index, pane.id.get())) - .group("sidebar-pane-heading") - .px_2() - .py_1() - .justify_between() - .border_1() - .border_color(colors.border_variant) - .rounded_sm() - .on_drop(move |dragged: &DraggedItem, window, cx| { - if dragged.space_entity != Some(move_space) || dragged.pane == pane_id { - return; - } - move_item( - Action::MoveToPane { - space: move_space, - item: dragged.item, - source: dragged.pane, - target: pane_id, - }, - window, - cx, - ) - }) - .child( - Label::new(format!("Pane {}", pane.id.get())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - h_flex() - .gap_1() - .child( - Label::new(format!( - "{count} tab{}", - if count == 1 { "" } else { "s" } - )) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div().visible_on_hover("sidebar-pane-heading").child( - IconButton::new( - ("close-sidebar-pane", pane.id.get()), - IconName::Close, - ) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Close All in Pane")) - .on_click( - move |_, window, cx| { - close( - Action::ClosePane { - space: close_space, - pane: pane_id, - }, - window, - cx, - ) - }, - ), - ), - ), - ) - .into_any_element(), - ); - if pane.entries.is_empty() { + let panes: Vec<_> = space.panes.iter().filter(|pane| !pane.entries.is_empty()).collect(); + if panes.len() <= 1 { + for entry in panes.into_iter().flat_map(|pane| &pane.entries) { groups.push( - div() - .px_3() - .py_1() - .child( - Label::new("Empty pane — drop a tab here") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) + row(index, entry, space.active && entry.selected, false, on.clone(), cx) .into_any_element(), ); - } - for entry in &pane.entries { - groups.push(row(index, entry, on.clone(), cx).into_any_element()); index += 1; } + continue; + } + let representative = panes + .iter() + .flat_map(|pane| &pane.entries) + .find(|entry| entry.selected) + .or_else(|| panes.iter().flat_map(|pane| &pane.entries).next()); + if let Some(entry) = representative { + groups.push(row(index, entry, space.active, true, on.clone(), cx).into_any_element()); + index += 1; } } @@ -259,7 +178,14 @@ fn header(space_switcher: AnyElement, new_item: AnyElement, on: Emit) -> impl In ) } -fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { +fn row( + index: usize, + entry: &Entry, + selected: bool, + grouped: bool, + on: Emit, + cx: &App, +) -> impl IntoElement { let colors = cx.theme().colors(); let close = on.clone(); @@ -269,27 +195,32 @@ fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { let close_space = entry.space; let dragged = DraggedItem { space: entry.space_key.clone(), - space_entity: Some(entry.space), pane: entry.pane, + index: entry.index, item: entry.key, title: entry.title.clone(), + selected, }; h_flex() .id(("session", index)) .role(Role::Tab) - .aria_label(entry.title.clone()) - .aria_selected(entry.selected) + .aria_label(if grouped { + format!("Pane group: {}", entry.title) + } else { + entry.title.clone() + }) + .aria_selected(selected) .group("session") .h(px(38.)) .px_2() .gap_2() .rounded_sm() - .when(entry.selected, |row| row.bg(colors.element_selected)) - .when(!entry.selected, |row| row.hover(|row| row.bg(colors.element_hover))) + .when(selected, |row| row.bg(colors.element_selected)) + .when(!selected, |row| row.hover(|row| row.bg(colors.element_hover))) .on_click(move |_, window, cx| { on(Action::Select { space: Some(space), item: select }, window, cx) }) - .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .when(!grouped, |row| row.on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone()))) .child(status_dot(entry, cx)) .child( v_flex() @@ -312,7 +243,11 @@ fn row(index: usize, entry: &Entry, on: Emit, cx: &App) -> impl IntoElement { .on_click(move |_, window, cx| { cx.stop_propagation(); close( - Action::Close { space: Some(close_space), item: close_key }, + if grouped { + Action::CloseGroup { space: close_space } + } else { + Action::Close { space: Some(close_space), item: close_key } + }, window, cx, ) diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 2dc50c5c..880a1c75 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -10,7 +10,7 @@ use ui::{Tab, TabPosition, Tooltip, prelude::*}; use super::Emit; -use super::{Action, Entry, status_dot}; +use super::{Action, DraggedItem, Entry, status_dot}; pub fn render( entries: &[Entry], @@ -71,9 +71,22 @@ fn tab( TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) }; let select = entry.key; + let select_item = on.clone(); + let move_item = on; let close_key = entry.key; let space = entry.space; let close_space = entry.space; + let target_pane = entry.pane; + let target_index = entry.index; + let target_space_key = entry.space_key.clone(); + let dragged = DraggedItem { + space: entry.space_key.clone(), + pane: entry.pane, + index: entry.index, + item: entry.key, + title: entry.title.clone(), + selected: entry.selected, + }; let close_slot: Option = entry.closable.then(|| { IconButton::new(("close", index), IconName::Close) .icon_size(IconSize::XSmall) @@ -91,7 +104,39 @@ fn tab( .position(position) .toggle_state(entry.selected) .on_click(move |_, window, cx| { - on(Action::Select { space: Some(space), item: select }, window, cx) + select_item(Action::Select { space: Some(space), item: select }, window, cx) + }) + .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| dragged.space == target_space_key) + }) + .drag_over::(move |tab, dragged, _, cx| { + let mut tab = tab + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + if target_index < dragged.index { + tab = tab.border_l_2(); + } else if target_index > dragged.index { + tab = tab.border_r_2(); + } + tab + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + move_item( + Action::MoveItem { + space, + item: dragged.item, + source: dragged.pane, + source_index: dragged.index, + target: target_pane, + target_index, + }, + window, + cx, + ); }) .start_slot(status_dot(entry, cx)) .end_slot::(close_slot) diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index e0153128..4b00e9c0 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -276,10 +276,6 @@ impl SettingsStore { &self.resolved } - pub fn content(&self) -> &SettingsContent { - &self.content - } - pub fn unreadable(&self) -> Option<&str> { self.unreadable.as_deref() } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 0e5e98ef..14346591 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -221,6 +221,7 @@ impl Space { for item in invalid { let _ = self.layout.remove_item(item); } + let _ = self.layout.prune_empty_panes(); } pub fn persisted(&self) -> PersistedSpace { @@ -279,8 +280,17 @@ impl Space { &mut self, pane: crate::workspace::PaneId, direction: Option, - ) { - self.drag_target = Some((pane, direction)); + ) -> bool { + let target = Some((pane, direction)); + if self.drag_target == target { + return false; + } + self.drag_target = target; + true + } + + pub fn clear_drag_target(&mut self) -> bool { + self.drag_target.take().is_some() } pub fn resize_divider(&mut self, axis_path: &[usize], divider: usize, fraction: f32) { @@ -345,16 +355,22 @@ impl Space { /// active item into it. Items remain unique; terminals are never cloned. pub fn split_and_move(&mut self, direction: SplitDirection) { let source = self.layout.active_pane(); - let active = self.layout.pane(source).and_then(|pane| pane.active()); - match self.layout.split_pane(source, direction) { - Ok(destination) => { - if let Some(active) = active - && let Err(error) = self.layout.move_item(active, destination, None) - { - self.problem = Some(error.to_string()); - } - } - Err(error) => self.problem = Some(error.to_string()), + self.split_and_move_in(source, direction); + } + + pub fn split_and_move_in( + &mut self, + source: crate::workspace::PaneId, + direction: SplitDirection, + ) { + if let Err(error) = self.layout.split_and_move(source, direction) { + self.problem = Some(error.to_string()); + } + } + + pub fn remove_empty_pane(&mut self, pane: crate::workspace::PaneId) { + if let Err(error) = self.layout.remove_empty_pane(pane) { + self.problem = Some(error.to_string()); } } @@ -393,13 +409,14 @@ impl Space { self.layout .panes() .flat_map(|pane| { - pane.items().iter().filter_map(move |id| { + pane.items().iter().enumerate().filter_map(move |(index, id)| { let item = self.items.get(id)?; Some(Entry { space, space_key: self.key(), key: *id, pane: pane.id, + index, title: item.title(), agent: item.agent(), ended: item.ended(), @@ -420,13 +437,15 @@ impl Space { entries: pane .items() .iter() - .filter_map(|id| { + .enumerate() + .filter_map(|(index, id)| { let item = self.items.get(id)?; Some(Entry { space, space_key: self.key(), key: *id, pane: pane.id, + index, title: item.title(), agent: item.agent(), ended: item.ended(), @@ -482,12 +501,10 @@ impl Space { self.fit_items(); } Action::Close { item, .. } => self.close_item(item, cx), - Action::MoveToPane { item, source, target, .. } => { - self.drop_item(item, source, target, None) - } Action::New | Action::NewInSpace { .. } - | Action::ClosePane { .. } + | Action::MoveItem { .. } + | Action::CloseGroup { .. } | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } @@ -554,6 +571,15 @@ impl Space { self.layout.activate_item(item).is_ok() } + pub fn activate_plugin_view(&mut self, view: gpui::EntityId) -> bool { + let Some(item) = self.items.iter().find_map(|(item, candidate)| { + candidate.as_plugin().filter(|plugin| plugin.view.entity_id() == view).map(|_| *item) + }) else { + return false; + }; + self.layout.activate_item(item).is_ok() + } + pub fn take_restoring_plugins(&mut self) -> Vec { std::mem::take(&mut self.restoring_plugins) } diff --git a/crates/zeddy/src/web_plugin.rs b/crates/zeddy/src/web_plugin.rs index e878b2f0..dee091e2 100644 --- a/crates/zeddy/src/web_plugin.rs +++ b/crates/zeddy/src/web_plugin.rs @@ -13,8 +13,9 @@ use std::{ rc::{Rc, Weak}, }; +use futures::{StreamExt as _, channel::mpsc}; use gpui::{ - AnyView, App, AppContext as _, Bounds, Context, Element, ElementId, GlobalElementId, + AnyView, App, AppContext as _, Bounds, Context, Element, ElementId, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, ParentElement as _, Pixels, Render, Size, Style, Styled as _, Window, div, }; @@ -24,6 +25,8 @@ use zeddy_plugin_host::FileBroker; use crate::session::SessionAccess; +pub type FocusHandler = Rc; + #[cfg(any(target_os = "macos", target_os = "linux"))] use wry::{ Rect, WebViewBuilder, @@ -36,10 +39,12 @@ pub fn view( broker: FileBroker, permissions: Permissions, session: Option, + on_focus: Option, window: &mut Window, cx: &mut App, ) -> AnyView { - cx.new(|cx| WebPluginView::new(entry, broker, permissions, session, window, cx)).into() + cx.new(|cx| WebPluginView::new(entry, broker, permissions, session, on_focus, window, cx)) + .into() } struct WebPluginView { @@ -47,6 +52,8 @@ struct WebPluginView { webview: Option>, #[cfg(target_os = "linux")] _gtk_pump: gpui::Task<()>, + #[cfg(any(target_os = "macos", target_os = "linux"))] + _focus_task: gpui::Task<()>, error: Option, } @@ -56,17 +63,27 @@ impl WebPluginView { broker: FileBroker, permissions: Permissions, session: Option, + on_focus: Option, window: &Window, _cx: &mut Context, ) -> Self { #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - let _ = (entry, broker, permissions, session, window, _cx); + let _ = (entry, broker, permissions, session, on_focus, window, _cx); return Self { error: Some("Web plugins are supported on macOS and Linux.".into()) }; } #[cfg(any(target_os = "macos", target_os = "linux"))] { + let (focus_tx, mut focus_rx) = mpsc::unbounded(); + let entity_id = _cx.entity_id(); + let focus_task = _cx.spawn(async move |_, cx| { + while focus_rx.next().await.is_some() { + if let Some(on_focus) = &on_focus { + let _ = cx.update(|cx| on_focus(entity_id, cx)); + } + } + }); #[cfg(target_os = "linux")] let gtk_pump = Self::pump_gtk(_cx); #[cfg(target_os = "linux")] @@ -74,6 +91,7 @@ impl WebPluginView { return Self { webview: None, _gtk_pump: gtk_pump, + _focus_task: focus_task, error: Some(format!("Could not initialize GTK: {error}")), }; } @@ -83,6 +101,7 @@ impl WebPluginView { webview: None, #[cfg(target_os = "linux")] _gtk_pump: gtk_pump, + _focus_task: focus_task, error: Some(format!("Plugin entry is unavailable: {}", entry.display())), }; }; @@ -97,6 +116,10 @@ impl WebPluginView { }) .with_initialization_script(BRIDGE) .with_ipc_handler(move |request| { + if is_focus_request(request.body()) { + let _ = focus_tx.unbounded_send(()); + return; + } let response = handle_request(&broker, &permissions, session.as_ref(), request.body()); if let Some(webview) = responder.borrow().as_ref().and_then(Weak::upgrade) @@ -123,6 +146,7 @@ impl WebPluginView { webview: None, #[cfg(target_os = "linux")] _gtk_pump: gtk_pump, + _focus_task: focus_task, error: Some(format!("Could not create the plugin webview: {error}")), }; } @@ -132,6 +156,7 @@ impl WebPluginView { webview: Some(webview), #[cfg(target_os = "linux")] _gtk_pump: gtk_pump, + _focus_task: focus_task, error: None, } } @@ -236,10 +261,20 @@ const BRIDGE: &str = r#" pending.set(id, [resolve, reject]); window.ipc.postMessage(JSON.stringify({ id, action, ...options })); }); + window.addEventListener("pointerdown", () => { + window.ipc.postMessage(JSON.stringify({ id: 0, action: "chartr.focus" })); + }, true); Object.defineProperty(window, "chartr", { value: Object.freeze({ invoke }) }); })(); "#; +fn is_focus_request(encoded: &str) -> bool { + serde_json::from_str::(encoded) + .ok() + .and_then(|request| request.get("action")?.as_str().map(str::to_owned)) + .is_some_and(|action| action == "chartr.focus") +} + #[derive(Deserialize)] struct HostRequest { id: u64, @@ -404,6 +439,7 @@ impl IntoElement for NativeWebViewElement { struct VisibleWebView { webview: Weak, frame: Option, + visible: bool, } #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -473,7 +509,7 @@ impl Element for NativeWebViewElement { bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, - _: &mut App, + cx: &mut App, ) -> Self::PrepaintState { let id = id.expect("native webview elements always have an id"); let frame = NativeFrame::snapped(bounds); @@ -482,13 +518,18 @@ impl Element for NativeWebViewElement { let mut lease = lease.unwrap_or_else(|| VisibleWebView { webview: Rc::downgrade(&self.webview), frame: None, + visible: false, }); if lease.frame != Some(frame) { let _ = self.webview.set_bounds(frame.wry()); lease.frame = Some(frame); } - if is_new { - let _ = self.webview.set_visible(true); + let visible = !cx.has_active_drag(); + if lease.visible != visible { + let _ = self.webview.set_visible(visible); + lease.visible = visible; + } + if is_new && visible { let _ = self.webview.focus_parent(); } ((), lease) @@ -519,6 +560,13 @@ mod tests { value.to_string() } + #[test] + fn internal_focus_messages_do_not_enter_the_plugin_host_action_api() { + assert!(is_focus_request(r#"{"id":0,"action":"chartr.focus"}"#)); + assert!(!is_focus_request(r#"{"id":1,"action":"project.read"}"#)); + assert!(!is_focus_request("not json")); + } + #[test] fn host_filesystem_actions_use_the_instance_broker() { let scratch = tempfile::tempdir().unwrap(); diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index f17f2a25..4073d387 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -6,7 +6,10 @@ //! invariant observable at one seam: an item belongs to exactly one pane in //! exactly one workspace. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; + +#[cfg(test)] +use std::collections::HashSet; use serde::{Deserialize, Serialize}; @@ -175,6 +178,7 @@ impl PaneAxis { Self { axis, members, flexes } } + #[cfg(test)] fn valid_flexes(&self) -> bool { self.flexes.len() == self.members.len() && self.flexes.iter().all(|flex| flex.is_finite() && *flex > 0.) @@ -337,6 +341,7 @@ impl PaneGroup { } } + #[cfg(test)] pub fn set_flexes(&mut self, axis_path: &[usize], flexes: Vec) -> Result<(), ModelError> { let mut member = &mut self.root; for &index in axis_path { @@ -571,6 +576,7 @@ impl Workspace { pub fn remove_item(&mut self, item: ItemId) -> Result<(), ModelError> { let pane = self.panes_by_item.remove(&item).ok_or(ModelError::ItemNotFound(item))?; self.panes.get_mut(&pane).ok_or(ModelError::PaneNotFound(pane))?.remove(item)?; + self.remove_pane_if_empty(pane)?; Ok(()) } @@ -594,11 +600,72 @@ impl Workspace { .expect("checked pane") .insert(item, destination_index); self.panes_by_item.insert(item, destination_pane); + self.remove_pane_if_empty(source)?; } self.active_pane = destination_pane; Ok(()) } + /// Zed removes a split pane when its last item leaves. The sole root pane + /// is retained so an empty workspace still has a drop/open target. + pub fn remove_empty_pane(&mut self, pane: PaneId) -> Result { + let empty = self.panes.get(&pane).ok_or(ModelError::PaneNotFound(pane))?.items.is_empty(); + if !empty { + return Ok(false); + } + self.remove_pane_if_empty(pane) + } + + /// Normalize restored layouts from older builds that retained every empty + /// split. Keep all panes containing items, or one active root when the + /// whole workspace is empty. + pub fn prune_empty_panes(&mut self) -> Result<(), ModelError> { + let ordered = self.center.panes(); + let keep = ordered + .iter() + .copied() + .find(|pane| self.panes.get(pane).is_some_and(|pane| !pane.items.is_empty())) + .or_else(|| ordered.contains(&self.active_pane).then_some(self.active_pane)) + .or_else(|| ordered.first().copied()); + let empty: Vec<_> = ordered + .into_iter() + .filter(|pane| { + Some(*pane) != keep + && self.panes.get(pane).is_some_and(|pane| pane.items.is_empty()) + }) + .collect(); + for pane in empty { + self.remove_pane_if_empty(pane)?; + } + Ok(()) + } + + fn remove_pane_if_empty(&mut self, pane: PaneId) -> Result { + if !self.panes.get(&pane).ok_or(ModelError::PaneNotFound(pane))?.items.is_empty() { + return Ok(false); + } + let ordered = self.center.panes(); + if ordered.len() == 1 { + return Ok(false); + } + let index = ordered + .iter() + .position(|candidate| *candidate == pane) + .ok_or(ModelError::PaneNotFound(pane))?; + let focus = ordered + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|i| ordered.get(i))) + .copied(); + if self.center.remove(pane)? { + self.panes.remove(&pane); + if self.active_pane == pane { + self.active_pane = focus.expect("a split pane always has a neighbor"); + } + return Ok(true); + } + Ok(false) + } + pub fn split_pane( &mut self, pane: PaneId, @@ -615,6 +682,28 @@ impl Workspace { Ok(new) } + /// Zed's `SplitMode::MovePane` behavior. Moving the sole tab would leave + /// its source empty and make ordinary empty-pane cleanup collapse the split + /// immediately. Zed instead inserts an empty pane on the opposite side and + /// keeps the sole tab focused, producing the same requested visual result. + pub fn split_and_move( + &mut self, + source: PaneId, + direction: SplitDirection, + ) -> Result { + let pane = self.panes.get(&source).ok_or(ModelError::PaneNotFound(source))?; + if pane.items.len() <= 1 { + let empty = self.split_pane(source, direction.opposite())?; + self.active_pane = source; + return Ok(empty); + } + + let active = pane.active.expect("a pane with multiple items always has an active item"); + let destination = self.split_pane(source, direction)?; + self.move_item(active, destination, None)?; + Ok(destination) + } + /// Join `source` into `destination`, moving every item in order and then /// collapsing the recursive group. pub fn join_pane(&mut self, source: PaneId, destination: PaneId) -> Result<(), ModelError> { @@ -632,13 +721,14 @@ impl Workspace { for item in items { self.move_item(item, destination, None)?; } - if self.center.remove(source)? { + if self.center.contains(source) && self.center.remove(source)? { self.panes.remove(&source); } self.active_pane = destination; Ok(()) } + #[cfg(test)] pub fn validate(&self) -> Result<(), ModelError> { let tree_panes = self.center.panes(); let tree_set: HashSet<_> = tree_panes.iter().copied().collect(); @@ -684,9 +774,13 @@ impl Workspace { pub enum ModelError { PaneNotFound(PaneId), ItemNotFound(ItemId), + #[cfg(test)] DuplicateItem(ItemId), + #[cfg(test)] ItemIndexMismatch(ItemId), + #[cfg(test)] InvalidActiveItem(PaneId), + #[cfg(test)] InvalidPaneTree, BadAxisPath, InvalidFlexes, @@ -705,7 +799,59 @@ mod tests { use super::*; #[test] - fn an_item_has_one_owner_even_when_moved_and_readded() { + fn splitting_a_lone_tab_with_two_existing_panes_matches_zeds_empty_pane_rule() { + for direction in + [SplitDirection::Up, SplitDirection::Down, SplitDirection::Left, SplitDirection::Right] + { + let mut workspace = Workspace::new(); + let first = workspace.active_pane(); + let second = workspace.split_pane(first, SplitDirection::Right).unwrap(); + let first_item = workspace.alloc_item(); + let second_item = workspace.alloc_item(); + workspace.add_item(first_item, Some(first), None).unwrap(); + workspace.add_item(second_item, Some(second), None).unwrap(); + + let empty = workspace.split_and_move(first, direction).unwrap(); + + let expected_order = if direction.increasing() { + vec![empty, first, second] + } else { + vec![first, empty, second] + }; + assert_eq!(workspace.center.panes(), expected_order, "{direction:?}"); + assert_eq!(workspace.pane(first).unwrap().items(), &[first_item]); + assert_eq!(workspace.pane(second).unwrap().items(), &[second_item]); + assert!(workspace.pane(empty).unwrap().items().is_empty()); + assert_eq!(workspace.active_pane(), first); + workspace.validate().unwrap(); + } + } + + #[test] + fn split_and_move_uses_the_explicit_source_when_another_pane_is_active() { + let mut workspace = Workspace::new(); + let first = workspace.active_pane(); + let second = workspace.split_pane(first, SplitDirection::Right).unwrap(); + let first_a = workspace.alloc_item(); + let first_b = workspace.alloc_item(); + let second_item = workspace.alloc_item(); + workspace.add_item(first_a, Some(first), None).unwrap(); + workspace.add_item(first_b, Some(first), None).unwrap(); + workspace.add_item(second_item, Some(second), None).unwrap(); + assert_eq!(workspace.active_pane(), second); + + let split = workspace.split_and_move(first, SplitDirection::Right).unwrap(); + + assert_eq!(workspace.center.panes(), vec![first, split, second]); + assert_eq!(workspace.pane(first).unwrap().items(), &[first_a]); + assert_eq!(workspace.pane(split).unwrap().items(), &[first_b]); + assert_eq!(workspace.pane(second).unwrap().items(), &[second_item]); + assert_eq!(workspace.active_pane(), split); + workspace.validate().unwrap(); + } + + #[test] + fn moving_the_last_item_removes_its_empty_source_pane_like_zed() { let mut workspace = Workspace::new(); let left = workspace.active_pane(); let right = workspace.split_pane(left, SplitDirection::Right).unwrap(); @@ -716,11 +862,51 @@ mod tests { workspace.add_item(item, Some(right), Some(0)).unwrap(); assert_eq!(workspace.pane_for_item(item), Some(right)); - assert!(!workspace.pane(left).unwrap().items().contains(&item)); + assert!(workspace.pane(left).is_none()); + assert_eq!(workspace.center.panes(), vec![right]); assert_eq!(workspace.pane(right).unwrap().items(), &[item]); workspace.validate().unwrap(); } + #[test] + fn closing_the_last_item_removes_a_split_but_retains_the_root() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + let split = workspace.split_pane(root, SplitDirection::Right).unwrap(); + let root_item = workspace.alloc_item(); + let split_item = workspace.alloc_item(); + workspace.add_item(root_item, Some(root), None).unwrap(); + workspace.add_item(split_item, Some(split), None).unwrap(); + + workspace.remove_item(split_item).unwrap(); + + assert_eq!(workspace.center.panes(), vec![root]); + assert!(workspace.pane(split).is_none()); + assert_eq!(workspace.active_pane(), root); + workspace.remove_item(root_item).unwrap(); + assert_eq!(workspace.center.panes(), vec![root]); + assert!(workspace.pane(root).unwrap().items().is_empty()); + workspace.validate().unwrap(); + } + + #[test] + fn restored_empty_splits_are_pruned_to_the_only_useful_pane() { + let mut workspace = Workspace::new(); + let root = workspace.active_pane(); + let useful = workspace.split_pane(root, SplitDirection::Right).unwrap(); + let empty = workspace.split_pane(useful, SplitDirection::Down).unwrap(); + let item = workspace.alloc_item(); + workspace.add_item(item, Some(useful), None).unwrap(); + workspace.activate_pane(empty).unwrap(); + + workspace.prune_empty_panes().unwrap(); + + assert_eq!(workspace.center.panes(), vec![useful]); + assert_eq!(workspace.active_pane(), useful); + assert_eq!(workspace.pane(useful).unwrap().items(), &[item]); + workspace.validate().unwrap(); + } + #[test] fn same_axis_splits_extend_the_axis_and_cross_axis_splits_nest() { let mut workspace = Workspace::new(); @@ -858,6 +1044,70 @@ mod tests { assert_eq!(workspace.pane_in_direction(SplitDirection::Up), None); } + #[test] + fn tab_drop_indices_match_zeds_before_and_after_target_semantics() { + let mut workspace = Workspace::new(); + let pane = workspace.active_pane(); + let a = workspace.alloc_item(); + let b = workspace.alloc_item(); + let c = workspace.alloc_item(); + let d = workspace.alloc_item(); + for item in [a, b, c, d] { + workspace.add_item(item, Some(pane), None).unwrap(); + } + + // A dragged onto C lands after C because it approached from the left. + workspace.move_item(a, pane, Some(2)).unwrap(); + assert_eq!(workspace.pane(pane).unwrap().items(), &[b, c, a, d]); + + // D dragged onto C lands before C because it approached from the right. + workspace.move_item(d, pane, Some(1)).unwrap(); + assert_eq!(workspace.pane(pane).unwrap().items(), &[b, d, c, a]); + assert_eq!(workspace.pane(pane).unwrap().active(), Some(d)); + workspace.validate().unwrap(); + } + + #[test] + fn edge_drop_split_inserts_beside_the_target_and_preserves_other_panes() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + let right = workspace.split_pane(left, SplitDirection::Right).unwrap(); + let left_a = workspace.alloc_item(); + let left_b = workspace.alloc_item(); + let right_a = workspace.alloc_item(); + workspace.add_item(left_a, Some(left), None).unwrap(); + workspace.add_item(left_b, Some(left), None).unwrap(); + workspace.add_item(right_a, Some(right), None).unwrap(); + + let dropped = workspace.split_pane(right, SplitDirection::Left).unwrap(); + workspace.move_item(left_a, dropped, Some(0)).unwrap(); + + assert_eq!(workspace.center.panes(), vec![left, dropped, right]); + assert_eq!(workspace.pane(left).unwrap().items(), &[left_b]); + assert_eq!(workspace.pane(dropped).unwrap().items(), &[left_a]); + assert_eq!(workspace.pane(right).unwrap().items(), &[right_a]); + assert_eq!(workspace.active_pane(), dropped); + workspace.validate().unwrap(); + } + + #[test] + fn center_drop_of_a_last_tab_collapses_only_its_empty_source() { + let mut workspace = Workspace::new(); + let left = workspace.active_pane(); + let right = workspace.split_pane(left, SplitDirection::Right).unwrap(); + let left_item = workspace.alloc_item(); + let right_item = workspace.alloc_item(); + workspace.add_item(left_item, Some(left), None).unwrap(); + workspace.add_item(right_item, Some(right), None).unwrap(); + + workspace.move_item(left_item, right, Some(0)).unwrap(); + + assert_eq!(workspace.center.panes(), vec![right]); + assert!(workspace.pane(left).is_none()); + assert_eq!(workspace.pane(right).unwrap().items(), &[left_item, right_item]); + workspace.validate().unwrap(); + } + #[test] fn a_long_edit_sequence_preserves_tree_and_item_ownership() { let mut workspace = Workspace::new(); diff --git a/docs/acceptance.md b/docs/acceptance.md index f736d480..11d20c40 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -25,10 +25,14 @@ Chartr Light. Capture and compare: - empty Ad-hoc startup, one folder, and several spaces; - Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; -- one pane, nested horizontal/vertical panes, resized dividers, zoom, and an - intentionally empty pane; -- terminal and plugin close buttons, active/hover/focus states, edge drop target, - grouped sidebar tabs, and the bulk-termination confirmation; +- empty root, one pane, nested horizontal/vertical panes, resized dividers, + zoom, and automatic split collapse after its last item moves or closes; +- terminal and plugin close buttons, active/hover/focus states, one collapsed + sidebar tab per multi-pane group, visible draggable tab bars in every + non-empty workspace pane, and the bulk-termination confirmation; +- Zed-style transient pane-body drop highlights: full-content center and + half-content left, right, top, and bottom targets, including nearest-edge + corner resolution and no split target over a pane's tab bar; - General, Appearance, Terminal, Hotkeys, Plugins, and a contributed plugin Settings view; - command palette, unavailable-folder recovery, broken-stream recovery, backend @@ -45,7 +49,20 @@ tier. Run the matrix with pointer and keyboard. Confirm `Cmd/Ctrl+W`, command palette, directional focus, split-and-move, move-to-existing-pane, join, zoom, Settings close/focus restoration, and `Ctrl+Tab` Settings-page cycling. Every drag outcome -must have a semantic action alternative. +must have a semantic action alternative. With two panes already open, invoke all +four split directions from the first lone-tab pane and confirm each creates the +expected adjacent empty drop target without moving, losing focus, or collapsing. + +For tab dragging, exercise each pane-body center and edge target, both corner +choices, before and after insertion on existing tabs, trailing-strip append, +movement between panes, and movement of the last source tab. Confirm the source +pane collapses only when it becomes empty, pane focus follows pointer selection, +`Escape` cancels without moving or cloning, and the platform clone modifier +(Option on macOS, Control elsewhere) clones only opt-in plugin items while all +other items move normally. Repeat with terminal, native-plugin, and web-plugin +items; tab headers must remain visible and draggable throughout. Clicking inside +a web plugin must activate its pane, and its native child view must yield during +a drag so neither the tab preview nor drop highlight is obscured. Inspect the GPUI accessibility tree on macOS and Linux. Tabs and Settings navigation must expose roles, labels, and selection; Zed buttons and menus must From 38b5adefeccd6cc24c7c40eef197c461f88ed607 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 00:02:00 +0800 Subject: [PATCH 004/110] Fix pane drag target selection --- crates/zeddy/src/app.rs | 43 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 89d6b50b..78b20278 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -2343,8 +2343,14 @@ impl Zeddy { .min_w_0() .group(drop_group.clone()) .on_drag_move::(move |event, _, cx| { + let Some(direction) = pane_drop_direction_for_drag(event) else { + // GPUI dispatches drag-move callbacks during capture even when the + // pointer is outside this element. Zed keeps split intent on each + // Pane entity; Chartr's shared space state must therefore ignore + // callbacks from every pane except the one under the pointer. + return; + }; let accepted = event.drag(cx).space == drag_space; - let direction = accepted.then(|| split_direction_for_drag(event)).flatten(); let _ = drag_move.update(cx, |this, cx| { let changed = this.active.clone().is_some_and(|space| { space.update(cx, |space, _| { @@ -3451,11 +3457,13 @@ fn setting_label(label: &'static str) -> AnyElement { Label::new(label).size(LabelSize::Small).color(Color::Muted).into_any_element() } -fn split_direction_for_drag(event: &DragMoveEvent) -> Option { +fn pane_drop_direction_for_drag( + event: &DragMoveEvent, +) -> Option> { let bounds = event.bounds; let x = event.event.position.x - bounds.left(); let y = event.event.position.y - bounds.top(); - split_direction_for_position( + pane_drop_direction_for_position( bounds.size.width.into(), bounds.size.height.into(), x.into(), @@ -3463,6 +3471,20 @@ fn split_direction_for_drag(event: &DragMoveEvent) -> Option Option> { + if x < 0. || x > width || y < 0. || y > height { + return None; + } + Some(split_direction_for_position(width, height, x, y)) +} + /// Zed's pane-body hit test. The edge band is 20% of the pane's shorter side; /// corners resolve to the nearest edge in Up, Right, Down, Left tie order. fn split_direction_for_position(width: f32, height: f32, x: f32, y: f32) -> Option { @@ -3664,7 +3686,20 @@ fn plugin_paths() -> Paths { #[cfg(test)] mod pane_drop_tests { - use super::{SplitDirection, split_direction_for_position}; + use super::{SplitDirection, pane_drop_direction_for_position, split_direction_for_position}; + + #[test] + fn panes_outside_the_pointer_do_not_overwrite_the_hovered_panes_drop_target() { + assert_eq!(pane_drop_direction_for_position(100., 100., -0.1, 50.), None); + assert_eq!(pane_drop_direction_for_position(100., 100., 100.1, 50.), None); + assert_eq!(pane_drop_direction_for_position(100., 100., 50., -0.1), None); + assert_eq!(pane_drop_direction_for_position(100., 100., 50., 100.1), None); + assert_eq!(pane_drop_direction_for_position(100., 100., 50., 50.), Some(None)); + assert_eq!( + pane_drop_direction_for_position(100., 100., 5., 50.), + Some(Some(SplitDirection::Left)) + ); + } #[test] fn zed_drop_zone_has_a_center_and_four_edge_bands() { From 0965e812649d00080cb4a08235520157b3b4f90f Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 01:29:07 +0800 Subject: [PATCH 005/110] Implement mixed workspace tabs and live session titles --- .plan/maps/chartr-zeddy-workspace/spec.md | 110 ++-- README.md | 40 +- crates/zeddy-herdr/src/control.rs | 135 ++++- crates/zeddy-herdr/src/protocol.rs | 68 ++- crates/zeddy/src/app.rs | 339 ++++++++----- crates/zeddy/src/chrome.rs | 93 ++-- crates/zeddy/src/chrome/sidebar.rs | 45 +- crates/zeddy/src/chrome/tabs.rs | 48 +- crates/zeddy/src/persistence.rs | 18 +- crates/zeddy/src/session.rs | 11 +- crates/zeddy/src/space.rs | 283 +++++++---- crates/zeddy/src/workspace.rs | 470 +++++++++++++++++- docs/acceptance.md | 21 +- .../0005-spaces-follow-zed-multi-workspace.md | 39 +- 14 files changed, 1297 insertions(+), 423 deletions(-) diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index 8886249b..d9c6a9ee 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -21,10 +21,13 @@ pipe instead of the proven recovery behavior from Chartr-rs. ## Solution Build Chartr around a focused implementation of Zed's multi-workspace model. The -application window owns multiple independent spaces. Each space owns a recursive -pane group; each pane exclusively owns ordered item instances; each terminal or -plugin tab is an item. A catalog may advertise plugin factories globally, but an -opened plugin instance belongs to exactly one pane and space. +application window owns multiple independent spaces. Each space owns an ordered +outer tab collection, and each outer tab owns one recursive Zed-style pane +workspace. A one-item workspace is presented as a standalone tab; a workspace +with multiple items or panes is presented as one grouped tab whose panes +exclusively own their ordered items. A catalog may advertise plugin factories +globally, but an opened plugin instance belongs to exactly one outer tab, pane, +and space. Provide complete Zed-style pane behavior: nested splits, divider resizing, directional focus, tab reordering and movement between panes, edge-drop splitting, @@ -32,12 +35,15 @@ joining, zooming/maximizing, contextual commands, a command palette, and complet layout restoration. Terminals are non-cloneable; plugins may explicitly declare clone support. Cross-space movement is not supported. -Offer tabbed and sidebar projections over the same model. Tabbed mode shows one -active space and local tab bars for each pane. Sidebar mode can show all spaces or -only the active space and collapses a multi-pane group to one tab labelled by its -last-active item. Every non-empty workspace pane keeps a visible, draggable tab -bar; only the active pane exposes compact split/zoom controls. Presentation never -changes item ownership. +Offer tabbed and sidebar projections over the same model. Both show every +standalone item and every pane group as one outer entry. Standalone terminals +use Herdr's live agent or foreground-process inference before falling back to +the persistent Herdr tab label; pane groups use the neutral `Grouped Tabs` +title. Tabbed mode places that collection beside the active space name; +sidebar mode places it beneath each visible space. Selecting a group renders its +local draggable pane tab bars, while selecting a standalone item renders no +redundant inner bar. Only the active pane exposes compact split/zoom controls. +Presentation never changes item ownership. Use Zed's existing GPUI, UI, and theme crates and their components, semantic colors, spacing, typography, focus, accessibility, menu, modal, notification, @@ -72,7 +78,7 @@ configuration automatically. 8. As a Chartr user, I want missing folders retained as unavailable spaces, so that transient mounts or moved folders do not destroy layout state. 9. As a Chartr user, I want to locate a missing space folder, so that I can reconnect its saved workspace state. 10. As a Chartr user, I want removing a space to leave its folder untouched, so that workspace cleanup cannot delete project data. -11. As a Chartr user, I want new sessions to open in the active pane of the targeted space, so that placement is predictable. +11. As a Chartr user, I want new sessions to open as standalone outer tabs in the targeted space, so that they do not silently join an unrelated pane group. 12. As a Chartr user, I want an inactive space's add control to activate that space before creating its session, so that sessions never enter the wrong owner. 13. As a Chartr user, I want nested horizontal and vertical splits, so that I can arrange several terminals and tools at once. 14. As a Chartr user, I want to resize split dividers, so that each pane receives useful screen space. @@ -82,7 +88,7 @@ configuration automatically. 18. As a Chartr user, I want to drop a tab on a pane edge to create a split, so that advanced layouts are direct and discoverable. 19. As a Chartr user, I want joining a pane to move its items into an adjacent pane, so that changing layout never kills work. 20. As a Chartr user, I want a split pane removed when its last item leaves, following Zed's default pane lifecycle, so that empty implementation structure does not accumulate in the UI. -21. As a Chartr user, I want at least one root pane to remain, so that an empty space is still usable. +21. As a Chartr user, I want an emptied outer tab removed while the space remains usable through its New action, so that phantom groups do not accumulate. 22. As a Chartr user, I want to zoom or maximize a pane, so that I can temporarily concentrate on one item. 23. As a Chartr user, I want terminals never to be cloned or mirrored, so that one session is never represented by multiple terminal tabs. 24. As a plugin author, I want to declare whether my item supports cloning, so that split cloning is safe and intentional. @@ -92,11 +98,11 @@ configuration automatically. 28. As a Chartr user, I want every non-empty workspace pane in either presentation mode to retain its own draggable tab bar, so that tab ownership and movement remain visible like Zed. 29. As a Chartr user, I want sidebar mode to show either all spaces or only the active space, so that I can choose overview or focus. 30. As a Chartr user, I want All Spaces to be the initial sidebar mode, so that a fresh installation exposes the whole cockpit. -31. As a Chartr user, I want a multi-pane group collapsed to one sidebar tab labelled by its last-active item, so that the sidebar represents the grouped workspace rather than every pane implementation detail. +31. As a Chartr user, I want standalone tabs and any number of pane groups mixed in one space, with each group collapsed to one outer entry titled `Grouped Tabs`, so that unrelated sessions remain independent without implying one child represents the group. 32. As a Chartr user, I want only the active non-empty pane to expose compact Zed-style split and zoom controls while all pane tab bars remain visible, so that advanced operations remain available without hiding the pane structure. 33. As a Chartr user, I want selecting an item in an inactive space to activate its space, pane, and item together, so that selection is one coherent action. 34. As a Chartr user, I want the sidebar width and presentation modes persisted, so that the application retains my preferred chrome. -35. As a Chartr user, I want the top-level visual pane group to be closable, so that I can end everything beneath it deliberately. +35. As a Chartr user, I want each top-level pane group to be closable, so that I can end everything beneath that group deliberately without closing its sibling tabs or groups. 36. As a Chartr user, I want confirmation before an operation kills multiple sessions, so that bulk actions are not accidentally destructive. 37. As a Chartr user, I want closing one terminal tab to terminate its Herdr session immediately, so that abandoned processes do not accumulate. 38. As a Chartr user, I want `Cmd+W` on macOS and `Ctrl+W` on Linux to close the active tab, so that closing follows familiar application behavior. @@ -160,20 +166,29 @@ configuration automatically. - The application window follows Zed's `MultiWorkspace` responsibility and owns ordered space entities plus one active space. - A space is the lifecycle and persistence boundary analogous to a Zed - `Workspace`. It owns one recursive pane group, its panes, active pane, item-to- - pane index, folder identity, and workspace-local restoration state. + `Workspace`. It owns an ordered, activation-tracked collection of outer + workspace tabs plus its folder identity and restoration state. Each outer tab + owns one existing recursive pane workspace; it is standalone when it has one + item and one pane, and grouped when it has multiple items or panes. - A pane exclusively owns its ordered items, active item, activation history, focus state, and drag state. Chrome never owns or reconstructs item state. - A pane group is a recursive axis tree with horizontal/vertical members and persisted flex ratios. Workspace-level event handling coordinates mutations. - Items expose lifecycle, serialization, focus, close, and optional clone behavior. A terminal session item is non-cloneable and closes destructively. -- An opened item entity may appear in only one pane and one space. Moving an item - removes it from its source pane before insertion. Cross-space moves are absent. +- A standalone terminal title is recomputed from Herdr on the two-second backend + refresh: display agent, internal agent, non-shell foreground process, then + persistent tab label/number. Exiting a process restores the fallback rather + than leaving a stale locally remembered title. +- An opened item entity may appear in only one outer workspace tab, pane, and + space. Moving an item removes it from its source before insertion; an emptied + outer tab disappears. Cross-space moves are absent. - Pane mutations use typed actions and pane events. Product chrome does not reach into pane internals to mutate vectors directly. -- Dragged tabs carry their source pane, source index, and item identity, and use - the same tab component for their drag preview. Drops on tabs use Zed's +- Dragged tabs carry their source outer tab, pane, source index, and item + identity, and use the same tab component for their drag preview. Standalone + outer entries may be dragged directly into any pane of the selected group. + Drops on pane tabs use Zed's source-aware before/after insertion rule; the trailing tab-strip target appends; pane-body center drops move into the target pane; and pane-body edge drops split it. Modifier cloning is available only to plugin items that @@ -189,14 +204,14 @@ configuration automatically. Native child webviews are hidden only for the duration of a GPUI drag so the dragged tab and pane drop highlight remain visible above their pixels. - Joining a pane moves items and collapses the axis. Moving or closing the last - item collapses a non-root pane; the sole root pane remains as the empty - workspace's open/drop target. As in Zed, invoking split-and-move on a pane - with only one item instead inserts an empty pane on the opposite side and - keeps the item focused, so the requested split is visible rather than being - immediately collapsed by the ordinary empty-source rule. -- The visual sidebar group is not an item. Its close control is a bulk lifecycle - action over all descendant items. Only top-level space groups expose that bulk - control; panes expose their own Close All action. + item collapses a non-root pane; an outer workspace tab disappears once no + items remain anywhere beneath it. As in Zed, invoking split-and-move on a + pane with only one item instead inserts an empty pane on the opposite side + and keeps the item focused, so the requested split is visible rather than + being immediately collapsed by the ordinary empty-source rule. +- A visual outer group is not an item. Its close control is a bulk lifecycle + action over only that outer tab's descendant items; panes expose their own + Close All action, and closing the containing space remains the larger boundary. - Single destructive item closes do not confirm. Any action that would terminate multiple live sessions confirms with an exact count. - The active item after removal follows Zed's activation-history behavior with a @@ -205,11 +220,11 @@ configuration automatically. defaults new sessions to the user's home directory or a configured replacement. - Folder spaces are deduplicated by canonical path. Display names are metadata and do not participate in identity. -- Tabbed and sidebar modes are alternate renderings of the same space/pane/item - state. Changing chrome never creates, moves, or closes an item. -- A multi-pane group projects to one sidebar tab labelled by its last-active - item. Closing that tab closes every item in the group through the normal bulk - lifecycle confirmation. +- Tabbed and sidebar modes are alternate renderings of the same outer-tab/pane/ + item state. Changing chrome never creates, moves, or closes an item. +- Each standalone item and pane group projects to one entry in both chromes. + Tabbed mode keeps these entries on the space-name row. Closing a group entry + closes every item in only that group through the normal bulk confirmation. - Sidebar mode persists an All Spaces or Active Space submode. Selecting an item from another space activates its space, pane, and item as one operation. - The sidebar is resizable with bounded width. Tabbed mode is active-space-only. @@ -274,12 +289,13 @@ configuration automatically. clean replacement, and detects a second failure within 60 seconds as a crash loop. It exposes Retry and no backend administration UI. - Backend loss removes terminal items and their session-bound plugins, collapses - newly empty splits, and retains spaces plus space-bound plugin items. + newly empty splits and outer tabs, and retains spaces plus space-bound plugins. - Herdr is authoritative for live session existence. Orphaned sessions enter the - owning space's last-active pane; stale saved terminal items are dropped. -- Versioned SQLite persistence stores space identities, pane trees, item records, - active state, split ratios, window bounds, sidebar width/submode, chrome mode, - expansion state, and migrations. + owning space as standalone outer tabs; stale saved terminal items are dropped. +- Versioned SQLite persistence stores space identities, ordered outer workspace + tabs, pane trees, item records, active state, split ratios, window bounds, + sidebar width/submode, chrome mode, expansion state, and migrations. A legacy + single pane tree migrates to one outer workspace tab. - User-editable settings, keymaps, and themes remain files. All persistent and runtime paths are namespaced to Chartr-zeddy; no automatic legacy import occurs. - The supported platforms are macOS and Linux. Windows remains deferred until the @@ -292,9 +308,9 @@ configuration automatically. host contract for contributions and permissions, and a real private Herdr process for transport behavior. Lower-level unit tests supplement rather than replace those seams. -- Ownership tests prove that an item entity is present in exactly one pane and one - space after add, reorder, cross-pane move, split-edge drop, join, close, restore, - and failed restore operations. +- Ownership tests prove that an item entity is present in exactly one outer tab, + pane, and space after add, outer-to-pane movement, cross-pane movement, + split-edge drop, join, close, restore, and failed restore operations. - Pane-group tests cover recursive split construction, flex resizing, directional adjacency/focus, edge-drop placement, join/collapse, empty-root invariants, zoom/maximize state, and serialization round trips. Property tests exercise long @@ -303,16 +319,18 @@ configuration automatically. pane join kills none; session-bound plugins cascade; bulk operations confirm; space removal kills all owned sessions; and normal application exit detaches. - Chrome tests assert that switching Tabbed, Sidebar/All Spaces, and Sidebar/Active - Space changes only presentation. Selecting and creating items from inactive - groups must activate the correct space and pane without duplication. + Space changes only presentation; both chromes show the same standalone and + grouped outer entries. Selecting and creating items from inactive groups must + activate the correct space, outer tab, and pane without duplication. - Action tests use semantic commands and contexts, including close, Settings close, split, join, focus, move, zoom, palette dispatch, and keybinding conflicts. - Settings tests cover default resolution, sparse user content, atomic updates, parse failure behavior, live observation, hotkey conflict reporting, theme selection, plugin page discovery, and restart-bound disclosures. -- Persistence tests launch from saved state and observe restored spaces, recursive - layouts, active state, window/chrome geometry, unavailable folders, missing - sessions, orphan sessions, missing plugins, and schema migrations. +- Persistence tests launch from saved state and observe restored spaces, ordered + outer tabs, multiple recursive layouts, active state, window/chrome geometry, + unavailable folders, missing sessions, orphan sessions, missing plugins, and + legacy single-layout migration. - Native plugin tests cover trust labeling, per-space singleton behavior, multi-instance opt-in, clone capability, close, disable, settings contribution, serialization, ABI mismatch, and restoration failure. diff --git a/README.md b/README.md index 21a21203..49b3671b 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,16 @@ One window owns ordered spaces and one active space, following Zed's `MultiWorkspace` responsibility. The permanent **Ad-hoc sessions** space is folderless and starts sessions in the home directory (or its configured replacement). Folder spaces are canonical-path identities with independent -recursive pane trees. +outer tab collections and recursive pane groups. Every terminal or plugin instance is one item owned by exactly one pane in one -space. Tabs never appear in several spaces. New sessions enter the active pane -of the selected space. A terminal item is non-cloneable; closing it terminates -its Herdr session. A plugin may opt into multiple instances, modifier cloning, -restoration, and explicit binding to one terminal session. +outer tab and space. Tabs never appear in several places. New sessions and +plugins start as standalone outer tabs in the selected space. Dragging a +standalone onto a pane moves it into that group; dropping in the center joins +the pane and dropping at an edge creates a split. A terminal item is +non-cloneable; closing it terminates its Herdr session. A plugin may opt into +multiple instances, modifier cloning, restoration, and explicit binding to one +terminal session. Panes support nested horizontal and vertical splits, divider resizing, directional focus, joining, zooming, and Zed-style tab dragging. Tab and @@ -36,16 +39,23 @@ highlight. Escape cancels a drag. The command palette provides keyboard alternatives for pane operations. `Cmd+W` on macOS and `Ctrl+W` on Linux closes the active item; operations that terminate multiple live sessions confirm with an exact count. As in Zed, a non-root pane disappears when its last item leaves; -one empty root remains so a space always has an open and drop target. Splitting -a lone-tab pane uses Zed's opposite-empty-pane rule, keeping the tab focused and -leaving the requested side available as a drop target. - -Sidebar and tabbed modes are projections over that same model. Sidebar mode can -show all spaces or only the active space. A multi-pane group collapses to one -sidebar tab labelled by its last-active item, while every pane in the workspace -keeps its Zed-style draggable tab bar. Tabbed mode shows one space and uses the -same pane tabs, including close controls for plugin items. Switching presentation -never reparents or recreates an item. +an outer tab disappears when its final item closes. An empty space remains +usable through its New action. Splitting a lone-tab pane uses Zed's +opposite-empty-pane rule, keeping the tab focused and leaving the requested side +available as a drop target. + +Sidebar and tabbed modes are projections over that same model. Both list every +standalone item and every pane group as one outer entry. Sidebar mode can show +all spaces or only the active space; tabbed mode keeps the active space's outer +entries beside its name. Selecting a group reveals its Zed-style draggable +pane-local tab bars, while a standalone has no duplicate inner bar. Switching +presentation never reparents or recreates an item. + +Terminal titles follow Herdr's live view of the PTY, as in Chartr-rs: a detected +agent wins, otherwise the non-shell foreground process is shown, and an idle +shell falls back to Herdr's persistent tab label or number. The same two-second +backend refresh that discovers sessions updates and clears these inferred +titles. Collapsed pane groups use the neutral title **Grouped Tabs**. ## Settings and persistence diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs index e042ce6d..719d3df7 100644 --- a/crates/zeddy-herdr/src/control.rs +++ b/crates/zeddy-herdr/src/control.rs @@ -9,6 +9,7 @@ //! does not decide that for them. use std::{ + collections::{HashMap, HashSet}, io::{BufRead, BufReader, Write}, os::unix::net::UnixStream, path::{Path, PathBuf}, @@ -23,7 +24,7 @@ use crate::{ Sidecar, WorkspaceId, protocol::{ self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, Pong, Request, Response, - TabCreateParams, WorkspaceCreateParams, WorkspaceList, + TabCreateParams, TabList, TabListParams, WorkspaceCreateParams, WorkspaceList, }, stream::Attachment, }; @@ -33,31 +34,48 @@ use crate::{ pub struct Session { pub id: PaneId, pub workspace: WorkspaceId, - /// What to put on the tab. herdr's title if it has one, the agent's name if - /// it knows one, and the id only as a last resort — a tab always has a name. + /// What to put on the tab: detected agent, non-shell foreground process, + /// persistent Herdr tab label/number, then the pane id as the last resort. pub title: String, /// The agent herdr believes is running in the pane, if any. pub agent: Option, pub cwd: Option, } -impl From for Session { - fn from(pane: protocol::Pane) -> Self { - let title = pane - .title - .filter(|t| !t.trim().is_empty()) - .or_else(|| pane.display_agent.clone()) +impl Session { + fn from_pane(pane: protocol::Pane, label: Option, running: Option) -> Self { + let title = running + .or(label) + .or_else(|| pane.title.as_deref().and_then(non_blank).map(str::to_owned)) .unwrap_or_else(|| pane.pane_id.clone()); + let agent = pane + .display_agent + .as_deref() + .and_then(non_blank) + .or_else(|| pane.agent.as_deref().and_then(non_blank)) + .map(str::to_owned); Self { id: PaneId(pane.pane_id), workspace: WorkspaceId(pane.workspace_id), title, - agent: pane.display_agent, + agent, cwd: pane.cwd.map(PathBuf::from), } } } +impl From for Session { + fn from(pane: protocol::Pane) -> Self { + let running = pane + .display_agent + .as_deref() + .and_then(non_blank) + .or_else(|| pane.agent.as_deref().and_then(non_blank)) + .map(str::to_owned); + Self::from_pane(pane, None, running) + } +} + /// A connection-per-request client for zeddy's private daemon. #[derive(Debug, Clone)] pub struct Client { @@ -205,7 +223,49 @@ impl Client { pub fn sessions(&self, workspace: Option<&WorkspaceId>) -> Result> { let params = PaneListParams { workspace_id: workspace.map(|w| w.0.as_str()) }; let list: PaneList = self.call("pane.list", ¶ms)?; - Ok(list.panes.into_iter().map(Session::from).collect()) + Ok(self.describe(list.panes)) + } + + /// Decorate Herdr panes with the same live titles used by Chartr-rs: + /// detected agent, foreground process, then persistent tab label. + /// + /// These are presentation questions. A failed `tab.list` or + /// `pane.process_info` must not hide an otherwise attachable terminal, so + /// each lookup degrades to the pane metadata already in hand. + fn describe(&self, panes: Vec) -> Vec { + let workspaces: HashSet<_> = panes.iter().map(|pane| pane.workspace_id.as_str()).collect(); + let mut tabs = HashMap::new(); + for workspace in workspaces { + let params = TabListParams { workspace_id: workspace }; + if let Ok(list) = self.call::<_, TabList>("tab.list", ¶ms) { + tabs.extend(list.tabs.into_iter().map(|tab| (tab.tab_id.clone(), tab))); + } + } + + panes + .into_iter() + .map(|pane| { + let agent = pane + .display_agent + .as_deref() + .and_then(non_blank) + .or_else(|| pane.agent.as_deref().and_then(non_blank)) + .map(str::to_owned); + let running = agent.clone().or_else(|| { + let params = protocol::PaneProcessParams { pane_id: &pane.pane_id }; + self.call::<_, protocol::PaneProcess>("pane.process_info", ¶ms) + .ok()? + .process_info + .foreground_program()? + .name + .as_deref() + .and_then(non_blank) + .map(str::to_owned) + }); + let label = tabs.get(&pane.tab_id).map(tab_label); + Session::from_pane(pane, label, running) + }) + .collect() } /// Every workspace, so the sidebar has something to list. @@ -253,14 +313,14 @@ impl Client { pub fn create_workspace(&self, cwd: &Path, label: Option<&str>) -> Result { let params = WorkspaceCreateParams { cwd: &cwd.to_string_lossy(), label }; let created: Created = self.call("workspace.create", ¶ms)?; - Ok(created.root_pane.into()) + Ok(self.describe(vec![created.root_pane]).remove(0)) } /// Start one more session in a workspace that is already open. pub fn start_session(&self, workspace: &WorkspaceId, cwd: Option<&str>) -> Result { let params = TabCreateParams { workspace_id: &workspace.0, cwd }; let created: Created = self.call("tab.create", ¶ms)?; - Ok(created.root_pane.into()) + Ok(self.describe(vec![created.root_pane]).remove(0)) } /// End a session. The pane and whatever is running in it both go. @@ -357,6 +417,19 @@ fn resolved(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_owned()) } +fn non_blank(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +fn tab_label(tab: &protocol::Tab) -> String { + match tab.label.as_deref().and_then(non_blank) { + Some(label) => label.to_owned(), + None if tab.number > 0 => tab.number.to_string(), + None => tab.tab_id.clone(), + } +} + /// Request ids only have to be unique within one connection, and there is one /// request per connection, so a counter is enough and a UUID would be theatre. fn next_id() -> String { @@ -373,16 +446,19 @@ mod tests { protocol::Pane { pane_id: id.to_owned(), workspace_id: "w1".to_owned(), + tab_id: "w1:t1".to_owned(), title: title.map(str::to_owned), display_agent: agent.map(str::to_owned), + agent: None, cwd: None, } } #[test] - fn a_tab_falls_back_from_title_to_agent_to_id() { - assert_eq!(Session::from(pane("p1", Some("build"), Some("claude"))).title, "build"); + fn a_tab_prefers_an_agent_then_falls_back_to_title_and_id() { + assert_eq!(Session::from(pane("p1", Some("build"), Some("claude"))).title, "claude"); assert_eq!(Session::from(pane("p1", None, Some("claude"))).title, "claude"); + assert_eq!(Session::from(pane("p1", Some("build"), None)).title, "build"); assert_eq!(Session::from(pane("p1", None, None)).title, "p1"); } @@ -391,6 +467,35 @@ mod tests { assert_eq!(Session::from(pane("p1", Some(" "), Some("codex"))).title, "codex"); } + #[test] + fn a_tab_label_falls_back_to_its_number_then_id() { + let mut tab = protocol::Tab { + tab_id: "w1:t2".to_owned(), + number: 2, + label: Some("build".to_owned()), + }; + assert_eq!(tab_label(&tab), "build"); + tab.label = Some(" ".to_owned()); + assert_eq!(tab_label(&tab), "2"); + tab.number = 0; + assert_eq!(tab_label(&tab), "w1:t2"); + } + + #[test] + fn process_info_excludes_the_waiting_shell() { + let process = protocol::ProcessInfo { + shell_pid: 10, + foreground_processes: vec![ + protocol::Process { pid: 10, name: Some("zsh".to_owned()) }, + protocol::Process { pid: 11, name: Some("htop".to_owned()) }, + ], + }; + assert_eq!( + process.foreground_program().and_then(|process| process.name.as_deref()), + Some("htop") + ); + } + #[test] fn a_missing_socket_names_the_socket_it_looked_for() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/zeddy-herdr/src/protocol.rs b/crates/zeddy-herdr/src/protocol.rs index 4ad98fc2..afa31103 100644 --- a/crates/zeddy-herdr/src/protocol.rs +++ b/crates/zeddy-herdr/src/protocol.rs @@ -1,6 +1,6 @@ //! herdr's wire types — exactly the ones zeddy sends or reads, and no more. //! -//! herdr's socket API has ninety methods. zeddy uses six of them. Modelling +//! herdr's socket API has ninety methods. zeddy uses eight of them. Modelling //! only those keeps the pin in [`crate::SUPPORTED_HERDR_VERSION`] honest: a //! herdr release can change anything zeddy does not name here without zeddy //! having an opinion about it. @@ -68,6 +68,11 @@ pub struct Pane { pub pane_id: String, #[serde(default)] pub workspace_id: String, + /// The Herdr tab containing this pane. Chartr keeps one session per Herdr + /// tab, so this is also where the session's persistent fallback label + /// lives. + #[serde(default)] + pub tab_id: String, /// herdr's own title for the pane, when it has worked one out. #[serde(default)] pub title: Option, @@ -76,10 +81,71 @@ pub struct Pane { /// terminal multiplexer: the backend already knows what a pane is running. #[serde(default)] pub display_agent: Option, + /// Herdr's internal agent name, used only when it has no display name. + #[serde(default)] + pub agent: Option, #[serde(default)] pub cwd: Option, } +/// A Herdr tab: the persistent name and ordering container for one Chartr +/// terminal session. +#[derive(Debug, Clone, Deserialize)] +pub struct Tab { + pub tab_id: String, + #[serde(default)] + pub number: u32, + #[serde(default)] + pub label: Option, +} + +#[derive(Debug, Serialize)] +pub struct TabListParams<'a> { + pub workspace_id: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct TabList { + #[serde(default)] + pub tabs: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PaneProcessParams<'a> { + pub pane_id: &'a str, +} + +/// The envelope returned by `pane.process_info`. +#[derive(Debug, Deserialize)] +pub struct PaneProcess { + pub process_info: ProcessInfo, +} + +/// The foreground process group of the PTY Herdr owns. +#[derive(Debug, Deserialize)] +pub struct ProcessInfo { + #[serde(default)] + pub shell_pid: u32, + #[serde(default)] + pub foreground_processes: Vec, +} + +impl ProcessInfo { + /// The program running in the pane, excluding the shell waiting at its own + /// prompt. + pub fn foreground_program(&self) -> Option<&Process> { + self.foreground_processes.iter().find(|process| process.pid != self.shell_pid) + } +} + +#[derive(Debug, Deserialize)] +pub struct Process { + #[serde(default)] + pub pid: u32, + #[serde(default)] + pub name: Option, +} + #[derive(Debug, Serialize)] pub struct WorkspaceCreateParams<'a> { pub cwd: &'a str, diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 78b20278..9f6a18f3 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -27,7 +27,7 @@ use zeddy_plugin_host::{Catalog, FileBroker, PaneSource, Paths, SettingsSource}; use crate::{ actions, - chrome::{self, Action, DraggedItem, Entry, SpaceEntries}, + chrome::{self, Action, DraggedItem, Entry, SpaceEntries, dragged_item_preview}, fonts::Fonts, item::PluginItem, keymap::{KeymapAction, KeymapStore}, @@ -44,7 +44,10 @@ use crate::{ space::{Kind as SpaceKind, Space, name_for}, spaces::{self, Registry}, terminal::{Appearance, TerminalElement}, - workspace::{Axis as PaneAxisDirection, Member, PaneId as LayoutPaneId, SplitDirection}, + workspace::{ + Axis as PaneAxisDirection, Member, PaneId as LayoutPaneId, SplitDirection, Workspace, + WorkspaceTabId, + }, }; const BACKEND_TIMEOUT: Duration = Duration::from_secs(10); @@ -408,8 +411,15 @@ impl Zeddy { continue; }; let probe = client.clone(); - if executor.spawn(async move { probe.answers() }).await { - let _ = this.update(cx, |this, _| { + let snapshot = executor.spawn(async move { probe.sessions(None) }).await; + let answers = if snapshot.is_ok() { + true + } else { + let probe = client.clone(); + executor.spawn(async move { probe.answers() }).await + }; + if answers { + let _ = this.update(cx, |this, cx| { if this.backend_restart_spent && this .backend_ready_since @@ -417,6 +427,11 @@ impl Zeddy { { this.backend_restart_spent = false; } + match snapshot { + Ok(infos) => this.distribute(infos, cx), + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); }); continue; } @@ -754,7 +769,6 @@ impl Zeddy { active: self.active.as_ref() == Some(space), removable: read.kind() == SpaceKind::Registered, available: read.available(), - panes: read.pane_entries(space.entity_id()), entries: read.entries(space.entity_id()), } }) @@ -786,14 +800,21 @@ impl Zeddy { target.update(cx, |space, cx| space.start_session(cx)); } } - Action::CloseGroup { space } => { + Action::CloseGroup { space, tab } => { let Some(space) = self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() else { return; }; - let ids = space.read(cx).all_item_ids(); - self.request_bulk_close(space, ids, false, window, cx); + let ids = space.read(cx).tab_item_ids(tab); + self.request_bulk_close(space, ids, false, "group", window, cx); + } + Action::MoveWorkspaceTab { space, tab, target_index } => { + if let Some(space) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + space.update(cx, |space, _| space.move_workspace_tab(tab, target_index)); + } } Action::CloseSpace { space } => self.request_close_space(space, window, cx), Action::RenameSpace { space } => { @@ -806,25 +827,6 @@ impl Zeddy { } } Action::LocateSpace { space } => self.locate_space(space, cx), - Action::MoveItem { space, item, source, source_index, target, target_index } => { - let Some(target_space) = - self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() - else { - return; - }; - if self.active.as_ref() != Some(&target_space) { - self.activate(target_space.clone(), window, cx); - } - let dragged = DraggedItem { - space: target_space.read(cx).key(), - pane: source, - index: source_index, - item, - title: String::new(), - selected: false, - }; - self.handle_item_drop(&dragged, target, target_index, false, window, cx); - } action @ (Action::Select { .. } | Action::Close { .. }) => { let target = match &action { Action::Select { space, .. } | Action::Close { space, .. } => space @@ -871,14 +873,18 @@ impl Zeddy { let Some(space) = self.active.clone() else { return; }; - let pane = space.read(cx).layout().active_pane(); - let ids = space.read(cx).pane_item_ids(pane); + let (Some(tab), Some(pane)) = space.read_with(cx, |space, _| { + (space.active_tab_id(), space.active_layout().map(Workspace::active_pane)) + }) else { + return; + }; + let ids = space.read(cx).pane_item_ids(tab, pane); if ids.is_empty() { - space.update(cx, |space, _| space.remove_empty_pane(pane)); + space.update(cx, |space, _| space.remove_empty_pane(tab, pane)); cx.notify(); return; } - self.request_bulk_close(space, ids, false, window, cx); + self.request_bulk_close(space, ids, false, "pane", window, cx); } fn request_close_space( @@ -895,7 +901,7 @@ impl Zeddy { return; } let ids = space.read(cx).all_item_ids(); - self.request_bulk_close(space, ids, true, window, cx); + self.request_bulk_close(space, ids, true, "space", window, cx); } fn request_bulk_close( @@ -903,6 +909,7 @@ impl Zeddy { space: Entity, ids: Vec, remove_space: bool, + noun: &'static str, window: &mut Window, cx: &mut Context, ) { @@ -917,7 +924,6 @@ impl Zeddy { return; } - let noun = if remove_space { "space" } else { "pane" }; let message = format!("Close this {noun} and terminate {terminal_count} sessions?"); let detail = "Closing is destructive: every underlying shell or agent process is terminated."; @@ -1341,12 +1347,13 @@ impl Zeddy { fn split_and_move_in( &mut self, + tab: WorkspaceTabId, pane: LayoutPaneId, direction: SplitDirection, cx: &mut Context, ) { if let Some(space) = self.active.clone() { - space.update(cx, |space, _| space.split_and_move_in(pane, direction)); + space.update(cx, |space, _| space.split_and_move_in(tab, pane, direction)); cx.notify(); } } @@ -1385,6 +1392,13 @@ impl Zeddy { } } + fn toggle_zoom_in(&mut self, tab: WorkspaceTabId, pane: LayoutPaneId, cx: &mut Context) { + if let Some(space) = self.active.clone() { + space.update(cx, |space, _| space.toggle_zoom_in(tab, pane)); + cx.notify(); + } + } + fn toggle_command_palette(&mut self, window: &mut Window, cx: &mut Context) { self.command_palette_open = !self.command_palette_open; self.command_palette_query.clear(); @@ -1883,6 +1897,7 @@ impl Zeddy { &mut self, space: Entity, source_item: crate::workspace::ItemId, + target_tab: WorkspaceTabId, target: LayoutPaneId, index: Option, window: &mut Window, @@ -1907,7 +1922,8 @@ impl Zeddy { bound_session.as_ref().and_then(|session| space.read(cx).session_access(session)); let on_focus = Some(Self::web_plugin_focus_handler(space.clone(), cx)); let unsafe_filesystem = self.settings.resolved().plugin(&key.plugin).unsafe_filesystem; - let Some(destination) = space.update(cx, |space, _| space.prepare_drop_destination(target)) + let Some(destination) = + space.update(cx, |space, _| space.prepare_drop_destination(target_tab, target)) else { return true; }; @@ -1942,6 +1958,7 @@ impl Zeddy { can_clone: capabilities.cloneable, restorable: capabilities.restorable, }, + target_tab, destination, index, cx, @@ -1956,6 +1973,7 @@ impl Zeddy { fn handle_item_drop( &mut self, dragged: &DraggedItem, + target_tab: WorkspaceTabId, target: LayoutPaneId, index: usize, allow_split: bool, @@ -1965,7 +1983,7 @@ impl Zeddy { let Some(space) = self.active.clone() else { return; }; - if space.read(cx).persisted().key != dragged.space { + if space.read(cx).key() != dragged.space { space.update(cx, |space, _| { space.clear_drag_target(); }); @@ -1973,18 +1991,33 @@ impl Zeddy { return; } if !allow_split { - space.update(cx, |space, _| space.set_drag_target(target, None)); + space.update(cx, |space, _| space.set_drag_target(target_tab, target, None)); } let clone = cfg!(target_os = "macos") && window.modifiers().alt || cfg!(not(target_os = "macos")) && window.modifiers().control; if clone - && self.clone_plugin_drop(space.clone(), dragged.item, target, Some(index), window, cx) + && self.clone_plugin_drop( + space.clone(), + dragged.item, + target_tab, + target, + Some(index), + window, + cx, + ) { cx.notify(); return; } space.update(cx, |space, _| { - space.drop_item(dragged.item, dragged.pane, target, Some(index)); + space.drop_item( + dragged.item, + dragged.tab, + dragged.pane, + target_tab, + target, + Some(index), + ); }); cx.notify(); } @@ -2004,21 +2037,38 @@ impl Zeddy { if active.is_some_and(|active| space.item(active).is_none()) { return message("That item is gone.", cx).into_any_element(); } - let pane_count = space.layout().center.panes().len(); let weak = cx.weak_entity(); - let workspace = if let Some(maximized) = space.layout().center.maximized { - self.render_pane(&space, maximized, pane_count > 1, &emit, &weak, window, cx) + let workspace = if let Some(tab) = space.workspace_tabs().active_tab() { + let layout = &tab.layout; + let show_pane_headers = tab.is_grouped(); + if let Some(maximized) = layout.center.maximized { + self.render_pane( + &space, + tab.id, + layout, + maximized, + show_pane_headers, + &emit, + &weak, + window, + cx, + ) + } else { + self.render_member( + &space, + tab.id, + layout, + &layout.center.root, + show_pane_headers, + &emit, + &weak, + &[], + window, + cx, + ) + } } else { - self.render_member( - &space, - &space.layout().center.root, - pane_count > 1, - &emit, - &weak, - &[], - window, - cx, - ) + message("No tabs. Create a new item to begin.", cx).into_any_element() }; let notices = self.workspace_notices(problem, cx); v_flex() @@ -2092,6 +2142,8 @@ impl Zeddy { fn render_member( &self, space: &Space, + tab_id: WorkspaceTabId, + layout: &Workspace, member: &Member, show_pane_headers: bool, on: &chrome::Emit, @@ -2101,9 +2153,17 @@ impl Zeddy { cx: &App, ) -> AnyElement { match member { - Member::Pane { pane } => { - self.render_pane(space, *pane, show_pane_headers, on, weak, window, cx) - } + Member::Pane { pane } => self.render_pane( + space, + tab_id, + layout, + *pane, + show_pane_headers, + on, + weak, + window, + cx, + ), Member::Axis(axis) => { let member_count = axis.members.len(); let children: Vec<_> = axis @@ -2127,6 +2187,8 @@ impl Zeddy { .min_h_0() .child(self.render_member( space, + tab_id, + layout, member, show_pane_headers, on, @@ -2163,6 +2225,7 @@ impl Zeddy { if let Some(space) = this.active.clone() { space.update(cx, |space, _| { space.resize_divider( + tab_id, &dragged.axis_path, dragged.divider, fraction, @@ -2197,6 +2260,7 @@ impl Zeddy { if let Some(space) = this.active.clone() { space.update(cx, |space, _| { space.resize_divider( + tab_id, &dragged.axis_path, dragged.divider, fraction, @@ -2217,6 +2281,8 @@ impl Zeddy { fn render_pane( &self, space: &Space, + tab_id: WorkspaceTabId, + layout: &Workspace, pane_id: LayoutPaneId, show_header: bool, on: &chrome::Emit, @@ -2224,12 +2290,12 @@ impl Zeddy { window: &mut Window, cx: &App, ) -> AnyElement { - let Some(pane) = space.layout().pane(pane_id) else { + let Some(pane) = layout.pane(pane_id) else { return message("Pane layout is unavailable.", cx).into_any_element(); }; - let active_pane = space.layout().active_pane() == pane_id; + let active_pane = layout.active_pane() == pane_id; let header = (show_header && pane.active().is_some()) - .then(|| self.pane_header(space, pane_id, on, weak, cx)); + .then(|| self.pane_header(space, tab_id, layout, pane_id, on, weak, cx)); let content = pane .active() .and_then(|id| space.item(id).map(|item| (id, item))) @@ -2304,8 +2370,8 @@ impl Zeddy { let drag_move = weak.clone(); let drop_item = weak.clone(); let focus_pane = weak.clone(); - let drop_group = format!("pane-drop-{}", pane_id.get()); - let drop_space = space.persisted().key; + let drop_group = format!("workspace-tab-{}-pane-drop-{}", tab_id.get(), pane_id.get()); + let drop_space = space.key(); let drag_space = drop_space.clone(); let pane_drop_index = pane .active() @@ -2313,22 +2379,19 @@ impl Zeddy { .unwrap_or(pane.items().len()); let drop_direction = space .drag_target() - .filter(|(pane, _)| *pane == pane_id) - .and_then(|(_, direction)| direction); + .filter(|(tab, pane, _)| *tab == tab_id && *pane == pane_id) + .and_then(|(_, _, direction)| direction); v_flex() - .id(("pane", pane_id.get() as usize)) + .id(format!("workspace-tab-{}-pane-{}", tab_id.get(), pane_id.get())) .relative() .size_full() .min_w_0() .min_h_0() .bg(cx.theme().colors().editor_background) - .when(active_pane, |pane| { - pane.border_1().border_color(cx.theme().colors().pane_focused_border) - }) .capture_any_mouse_down(move |_, window, cx| { let _ = focus_pane.update(cx, |this, cx| { if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.activate_pane(pane_id)); + space.update(cx, |space, _| space.activate_pane(tab_id, pane_id)); } window.focus(&this.focus, cx); cx.notify(); @@ -2355,7 +2418,7 @@ impl Zeddy { let changed = this.active.clone().is_some_and(|space| { space.update(cx, |space, _| { if accepted { - space.set_drag_target(pane_id, direction) + space.set_drag_target(tab_id, pane_id, direction) } else { space.clear_drag_target() } @@ -2373,6 +2436,7 @@ impl Zeddy { let _ = drop_item.update(cx, |this, cx| { this.handle_item_drop( &dragged, + tab_id, pane_id, pane_drop_index, true, @@ -2389,18 +2453,20 @@ impl Zeddy { fn pane_header( &self, space: &Space, + tab_id: WorkspaceTabId, + layout: &Workspace, pane_id: LayoutPaneId, on: &chrome::Emit, weak: &gpui::WeakEntity, cx: &App, ) -> AnyElement { - let Some(pane) = space.layout().pane(pane_id) else { + let Some(pane) = layout.pane(pane_id) else { return div().into_any_element(); }; let active_index = pane.active().and_then(|active| pane.items().iter().position(|item| *item == active)); - let space_key = space.persisted().key; + let space_key = space.key(); let tabs = pane.items().iter().enumerate().filter_map(|(index, id)| { let item = space.item(*id)?; let selected = pane.active() == Some(*id); @@ -2419,11 +2485,13 @@ impl Zeddy { let drop_space = space_key.clone(); let dragged = DraggedItem { space: space_key.clone(), + tab: tab_id, pane: pane_id, index, item: *id, title: item.title(), selected, + top_level: false, }; Some( Tab::new(format!("pane-{}-item-{}", pane_id.get(), id.get())) @@ -2435,7 +2503,9 @@ impl Zeddy { .on_click(move |_, window, cx| { select_item(Action::Select { space: None, item: select }, window, cx) }) - .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .on_drag(dragged, |dragged, offset, _, cx| { + dragged_item_preview(dragged, offset, cx) + }) .can_drop(move |value, _, _| { value .downcast_ref::() @@ -2456,7 +2526,9 @@ impl Zeddy { .on_drop(move |dragged: &DraggedItem, window, cx| { let dragged = dragged.clone(); let _ = drop_item.update(cx, |this, cx| { - this.handle_item_drop(&dragged, pane_id, index, false, window, cx); + this.handle_item_drop( + &dragged, tab_id, pane_id, index, false, window, cx, + ); }); }) .end_slot( @@ -2495,13 +2567,21 @@ impl Zeddy { .on_drop(move |dragged: &DraggedItem, window, cx| { let dragged = dragged.clone(); let _ = append_drop.update(cx, |this, cx| { - this.handle_item_drop(&dragged, pane_id, append_index, false, window, cx); + this.handle_item_drop( + &dragged, + tab_id, + pane_id, + append_index, + false, + window, + cx, + ); }); }); - TabBar::new(format!("pane-{}-tabs", pane_id.get())) + TabBar::new(format!("workspace-tab-{}-pane-{}-tabs", tab_id.get(), pane_id.get())) .children(tabs) .child(tab_bar_drop_target) - .end_child(pane_controls(weak, pane_id)) + .end_child(pane_controls(weak, tab_id, pane_id)) .into_any_element() } @@ -3301,12 +3381,7 @@ impl Render for Zeddy { self.persist_if_changed(cx); let entries = self.entries(cx); let sidebar_spaces = self.sidebar_spaces(cx); - let pane_count = self - .active_space() - .map(|space| space.read(cx).layout().center.panes().len()) - .unwrap_or(0); - let chrome_entries: &[Entry] = - if self.mode == Mode::Tabs && pane_count > 1 { &[] } else { &entries }; + let chrome_entries: &[Entry] = &entries; let switcher = self.space_switcher(window, cx); let new_item = self.new_item_menu(cx); let (background, text, workspace_background) = { @@ -3539,67 +3614,81 @@ fn pane_resize_handle(dragged: DraggedPaneDivider, axis: PaneAxisDirection) -> i .occlude() } -fn pane_controls(weak: &gpui::WeakEntity, pane_id: LayoutPaneId) -> AnyElement { +fn pane_controls( + weak: &gpui::WeakEntity, + tab_id: WorkspaceTabId, + pane_id: LayoutPaneId, +) -> AnyElement { let focus = weak.clone(); let split = weak.clone(); let zoom = weak.clone(); h_flex() - .id(("pane-controls", pane_id.get())) + .id(format!("workspace-tab-{}-pane-{}-controls", tab_id.get(), pane_id.get())) .gap_0p5() .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { let _ = focus.update(cx, |this, cx| { if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.activate_pane(pane_id)); + space.update(cx, |space, _| space.activate_pane(tab_id, pane_id)); } cx.notify(); }); }) .child( - PopoverMenu::new(("pane-split-menu", pane_id.get())) - .trigger_with_tooltip( - IconButton::new(("pane-split", pane_id.get()), IconName::Split) - .icon_size(IconSize::XSmall), - Tooltip::text("Split Pane"), + PopoverMenu::new(format!( + "workspace-tab-{}-pane-{}-split-menu", + tab_id.get(), + pane_id.get() + )) + .trigger_with_tooltip( + IconButton::new( + format!("workspace-tab-{}-pane-{}-split", tab_id.get(), pane_id.get()), + IconName::Split, ) - .anchor(Anchor::TopRight) - .menu(move |window, cx| { - let split = split.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - let left = split.clone(); - let right = split.clone(); - let up = split.clone(); - let down = split.clone(); - menu.entry("Split Left", None, move |_, cx| { - let _ = left.update(cx, |this, cx| { - this.split_and_move_in(pane_id, SplitDirection::Left, cx) - }); - }) - .entry("Split Right", None, move |_, cx| { - let _ = right.update(cx, |this, cx| { - this.split_and_move_in(pane_id, SplitDirection::Right, cx) - }); - }) - .entry("Split Up", None, move |_, cx| { - let _ = up.update(cx, |this, cx| { - this.split_and_move_in(pane_id, SplitDirection::Up, cx) - }); - }) - .entry("Split Down", None, move |_, cx| { - let _ = down.update(cx, |this, cx| { - this.split_and_move_in(pane_id, SplitDirection::Down, cx) - }); - }) - })) - }), + .icon_size(IconSize::XSmall), + Tooltip::text("Split Pane"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let split = split.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + let left = split.clone(); + let right = split.clone(); + let up = split.clone(); + let down = split.clone(); + menu.entry("Split Left", None, move |_, cx| { + let _ = left.update(cx, |this, cx| { + this.split_and_move_in(tab_id, pane_id, SplitDirection::Left, cx) + }); + }) + .entry("Split Right", None, move |_, cx| { + let _ = right.update(cx, |this, cx| { + this.split_and_move_in(tab_id, pane_id, SplitDirection::Right, cx) + }); + }) + .entry("Split Up", None, move |_, cx| { + let _ = up.update(cx, |this, cx| { + this.split_and_move_in(tab_id, pane_id, SplitDirection::Up, cx) + }); + }) + .entry("Split Down", None, move |_, cx| { + let _ = down.update(cx, |this, cx| { + this.split_and_move_in(tab_id, pane_id, SplitDirection::Down, cx) + }); + }) + })) + }), ) .child( - IconButton::new(("pane-zoom", pane_id.get()), IconName::Maximize) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Toggle Pane Zoom")) - .on_click(move |_, _, cx| { - let _ = zoom.update(cx, |this, cx| this.toggle_zoom(cx)); - }), + IconButton::new( + format!("workspace-tab-{}-pane-{}-zoom", tab_id.get(), pane_id.get()), + IconName::Maximize, + ) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Toggle Pane Zoom")) + .on_click(move |_, _, cx| { + let _ = zoom.update(cx, |this, cx| this.toggle_zoom_in(tab_id, pane_id, cx)); + }), ) .into_any_element() } diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 490a3ba8..6ac9d02d 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -1,16 +1,16 @@ //! The two chromes, and the one thing they have in common. //! -//! A chrome is a list of sessions with one of them selected. Sidebar mode draws -//! that list down the left; tabs mode draws it across the top. Neither knows -//! anything else about the app, which is what keeps the two implementations to -//! a screenful each: they take [`Entry`] values and emit stable item keys. +//! A chrome is a list of outer workspace tabs with one selected. A one-item tab +//! is standalone; a multi-item pane workspace is one grouped entry. Sidebar +//! mode draws the list down the left and tabs mode draws it across the top. +//! Neither owns workspace state: both take [`Entry`] values and emit stable ids. pub mod sidebar; pub mod tabs; use std::rc::Rc; -use crate::workspace::{ItemId, PaneId}; +use crate::workspace::{ItemId, PaneId, WorkspaceTabId}; use gpui::EntityId; use ui::{Tab, prelude::*}; @@ -20,6 +20,7 @@ pub struct Entry { pub space: EntityId, pub space_key: String, pub key: ItemId, + pub tab: WorkspaceTabId, pub pane: PaneId, pub index: usize, pub title: String, @@ -31,6 +32,8 @@ pub struct Entry { pub ended: bool, pub selected: bool, pub closable: bool, + pub grouped: bool, + pub item_count: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -40,50 +43,20 @@ pub struct SpaceEntries { pub active: bool, pub removable: bool, pub available: bool, - pub panes: Vec, - pub entries: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaneEntries { - pub id: PaneId, pub entries: Vec, } /// What the user did to the chrome. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { - Select { - space: Option, - item: ItemId, - }, - Close { - space: Option, - item: ItemId, - }, - MoveItem { - space: EntityId, - item: ItemId, - source: PaneId, - source_index: usize, - target: PaneId, - target_index: usize, - }, - CloseGroup { - space: EntityId, - }, - CloseSpace { - space: EntityId, - }, - RenameSpace { - space: EntityId, - }, - LocateSpace { - space: EntityId, - }, - NewInSpace { - space: EntityId, - }, + Select { space: Option, item: ItemId }, + Close { space: Option, item: ItemId }, + CloseGroup { space: EntityId, tab: WorkspaceTabId }, + MoveWorkspaceTab { space: EntityId, tab: WorkspaceTabId, target_index: usize }, + CloseSpace { space: EntityId }, + RenameSpace { space: EntityId }, + LocateSpace { space: EntityId }, + NewInSpace { space: EntityId }, New, ToggleMode, ToggleSidebarScope, @@ -107,11 +80,13 @@ impl Render for DraggedSidebar { #[derive(Clone)] pub struct DraggedItem { pub space: String, + pub tab: WorkspaceTabId, pub pane: PaneId, pub index: usize, pub item: ItemId, pub title: String, pub selected: bool, + pub top_level: bool, } impl Render for DraggedItem { @@ -122,6 +97,38 @@ impl Render for DraggedItem { } } +/// Builds the one drag preview used by every Chartr tab surface. +/// +/// GPUI positions a drag view at `pointer - offset_within_source`, which is +/// perfect when the preview has the source element's dimensions. Chartr's +/// sidebar rows and outer tabs are often much wider than the compact preview, +/// though, so using the source offset makes the visible ghost trail behind the +/// pointer. Translating the compact preview by that same offset locks its +/// visible origin to GPUI's current-frame pointer position. +pub(crate) fn dragged_item_preview( + dragged: &DraggedItem, + source_offset: gpui::Point, + cx: &mut App, +) -> gpui::Entity { + let dragged = dragged.clone(); + cx.new(|_| DraggedItemPreview { dragged, source_offset }) +} + +pub(crate) struct DraggedItemPreview { + dragged: DraggedItem, + source_offset: gpui::Point, +} + +impl Render for DraggedItemPreview { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().relative().left(self.source_offset.x).top(self.source_offset.y).child( + Tab::new(("dragged-item-preview", self.dragged.item.get() as usize)) + .toggle_state(self.dragged.selected) + .child(Label::new(self.dragged.title.clone()).size(LabelSize::Small)), + ) + } +} + /// The dot that carries a session's state, in the one place both chromes agree /// on what it means. pub fn status_dot(entry: &Entry, cx: &App) -> impl IntoElement { diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 8caa7db2..11934bef 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -1,4 +1,4 @@ -//! Sidebar mode: the session list down the left. +//! Sidebar mode: standalone tabs and pane groups down the left. //! //! The mode for many long-lived sessions. There is room here for the things a //! tab cannot hold — the agent's name under the title, and a close button that @@ -9,7 +9,9 @@ use ui::{Tooltip, prelude::*}; use super::Emit; -use super::{Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, status_dot}; +use super::{ + Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, status_dot, +}; /// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a /// preference to persist, a drag handle to hit-test, and a minimum to enforce, @@ -98,24 +100,11 @@ pub fn render( ) .into_any_element(), ); - let panes: Vec<_> = space.panes.iter().filter(|pane| !pane.entries.is_empty()).collect(); - if panes.len() <= 1 { - for entry in panes.into_iter().flat_map(|pane| &pane.entries) { - groups.push( - row(index, entry, space.active && entry.selected, false, on.clone(), cx) - .into_any_element(), - ); - index += 1; - } - continue; - } - let representative = panes - .iter() - .flat_map(|pane| &pane.entries) - .find(|entry| entry.selected) - .or_else(|| panes.iter().flat_map(|pane| &pane.entries).next()); - if let Some(entry) = representative { - groups.push(row(index, entry, space.active, true, on.clone(), cx).into_any_element()); + for entry in &space.entries { + groups.push( + row(index, entry, space.active && entry.selected, entry.grouped, on.clone(), cx) + .into_any_element(), + ); index += 1; } } @@ -191,15 +180,18 @@ fn row( let select = entry.key; let close_key = entry.key; + let close_tab = entry.tab; let space = entry.space; let close_space = entry.space; let dragged = DraggedItem { space: entry.space_key.clone(), + tab: entry.tab, pane: entry.pane, index: entry.index, item: entry.key, title: entry.title.clone(), selected, + top_level: true, }; h_flex() .id(("session", index)) @@ -220,7 +212,9 @@ fn row( .on_click(move |_, window, cx| { on(Action::Select { space: Some(space), item: select }, window, cx) }) - .when(!grouped, |row| row.on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone()))) + .when(!grouped, |row| { + row.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) + }) .child(status_dot(entry, cx)) .child( v_flex() @@ -233,6 +227,13 @@ fn row( ) }), ) + .when(grouped, |row| { + row.child( + Label::new(format!("{} tabs", entry.item_count)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }) .when(entry.closable, |row| { row.child( // Revealed on hover so a list of ten sessions is ten titles rather @@ -244,7 +245,7 @@ fn row( cx.stop_propagation(); close( if grouped { - Action::CloseGroup { space: close_space } + Action::CloseGroup { space: close_space, tab: close_tab } } else { Action::Close { space: Some(close_space), item: close_key } }, diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 880a1c75..12fbe55a 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -1,4 +1,4 @@ -//! Tabs mode: the session list across the top. +//! Tabs mode: standalone tabs and pane groups beside the active space name. //! //! The mode for a handful of sessions you are switching between quickly. A tab //! has no second line, so the agent's name is dropped here rather than @@ -10,7 +10,7 @@ use ui::{Tab, TabPosition, Tooltip, prelude::*}; use super::Emit; -use super::{Action, DraggedItem, Entry, status_dot}; +use super::{Action, DraggedItem, Entry, dragged_item_preview, status_dot}; pub fn render( entries: &[Entry], @@ -72,20 +72,23 @@ fn tab( }; let select = entry.key; let select_item = on.clone(); - let move_item = on; + let move_tab = on; let close_key = entry.key; + let close_tab = entry.tab; + let grouped = entry.grouped; let space = entry.space; let close_space = entry.space; - let target_pane = entry.pane; - let target_index = entry.index; + let target_index = index; let target_space_key = entry.space_key.clone(); let dragged = DraggedItem { space: entry.space_key.clone(), + tab: entry.tab, pane: entry.pane, - index: entry.index, + index, item: entry.key, title: entry.title.clone(), selected: entry.selected, + top_level: true, }; let close_slot: Option = entry.closable.then(|| { IconButton::new(("close", index), IconName::Close) @@ -93,24 +96,38 @@ fn tab( .tooltip(Tooltip::text("Close")) .on_click(move |_, window, cx| { cx.stop_propagation(); - close(Action::Close { space: Some(close_space), item: close_key }, window, cx) + close( + if grouped { + Action::CloseGroup { space: close_space, tab: close_tab } + } else { + Action::Close { space: Some(close_space), item: close_key } + }, + window, + cx, + ) }) .into_any_element() }); Tab::new(("tab", index)) .role(Role::Tab) - .aria_label(entry.title.clone()) + .aria_label(if entry.grouped { + format!("Pane group: {}", entry.title) + } else { + entry.title.clone() + }) .aria_selected(entry.selected) .position(position) .toggle_state(entry.selected) .on_click(move |_, window, cx| { select_item(Action::Select { space: Some(space), item: select }, window, cx) }) - .on_drag(dragged, |dragged, _, _, cx| cx.new(|_| dragged.clone())) + .when(!entry.grouped, |tab| { + tab.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) + }) .can_drop(move |value, _, _| { value .downcast_ref::() - .is_some_and(|dragged| dragged.space == target_space_key) + .is_some_and(|dragged| dragged.space == target_space_key && dragged.top_level) }) .drag_over::(move |tab, dragged, _, cx| { let mut tab = tab @@ -125,15 +142,8 @@ fn tab( tab }) .on_drop(move |dragged: &DraggedItem, window, cx| { - move_item( - Action::MoveItem { - space, - item: dragged.item, - source: dragged.pane, - source_index: dragged.index, - target: target_pane, - target_index, - }, + move_tab( + Action::MoveWorkspaceTab { space, tab: dragged.tab, target_index }, window, cx, ); diff --git a/crates/zeddy/src/persistence.rs b/crates/zeddy/src/persistence.rs index 0807cedc..253be239 100644 --- a/crates/zeddy/src/persistence.rs +++ b/crates/zeddy/src/persistence.rs @@ -13,7 +13,7 @@ use anyhow::{Context as _, Result}; use rusqlite::{Connection, OptionalExtension as _, params}; use serde::{Deserialize, Serialize}; -use crate::{mode::Mode, workspace::Workspace}; +use crate::{mode::Mode, workspace::WorkspaceTabs}; pub const STATE_FILE: &str = "state.sqlite"; const SCHEMA_VERSION: i64 = 1; @@ -68,7 +68,7 @@ pub struct PersistedSpace { pub name: String, pub path: Option, pub kind: SpaceKind, - pub layout: Workspace, + pub layout: WorkspaceTabs, pub items: Vec, pub expanded: bool, } @@ -226,11 +226,17 @@ mod tests { use super::*; fn space(key: &str) -> PersistedSpace { - let mut layout = Workspace::new(); - let root = layout.active_pane(); - let right = layout.split_pane(root, crate::workspace::SplitDirection::Right).unwrap(); + let mut layout = WorkspaceTabs::new(); let item = layout.alloc_item(); - layout.add_item(item, Some(right), None).unwrap(); + layout.push_standalone(item).unwrap(); + let tab = layout.active_tab_id().unwrap(); + let root = layout.active_workspace().unwrap().active_pane(); + let right = layout + .workspace_mut(tab) + .unwrap() + .split_pane(root, crate::workspace::SplitDirection::Right) + .unwrap(); + layout.workspace_mut(tab).unwrap().move_item(item, right, None).unwrap(); PersistedSpace { key: key.to_owned(), name: "Project".to_owned(), diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 866c5790..98b967bb 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -122,15 +122,10 @@ impl Session { self.ended.lock().expect("ended mutex").clone() } - /// The title to show: whatever the program set, else what herdr called it. + /// The live title inferred by the control plane: detected agent, foreground + /// process, then Herdr's persistent tab label. pub fn title(&self) -> String { - self.terminal - .lock() - .expect("terminal mutex") - .screen() - .title - .filter(|title| !title.trim().is_empty()) - .unwrap_or_else(|| self.info.title.clone()) + self.info.title.clone() } /// Send typed bytes to the session. diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 14346591..c372ea22 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -1,8 +1,9 @@ //! One space: a folder, its backend workspace, and its open items. //! //! A space is independently stateful in the same way a Zed `Workspace` held by -//! `MultiWorkspace` is: it owns its sessions and active item, while the parent -//! owns the ordered collection and decides which space the window presents. +//! `MultiWorkspace` is: it owns its sessions, outer workspace tabs, and active +//! item, while the parent owns the ordered spaces and decides which one the +//! window presents. use std::{ collections::{HashMap, HashSet}, @@ -14,12 +15,12 @@ use gpui::{Context, Task}; use zeddy_herdr::{PaneId, WorkspaceId, control::Client}; use crate::{ - chrome::{Action, Entry, PaneEntries}, + chrome::{Action, Entry}, item::{Item, PluginItem, SessionItem}, persistence::{PersistedItem, PersistedSpace, SpaceKind as PersistedSpaceKind}, session::Session, spaces, - workspace::{ItemId, SplitDirection, Workspace}, + workspace::{ItemId, SplitDirection, Workspace, WorkspaceTabId, WorkspaceTabs}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,7 +35,7 @@ pub struct Space { kind: Kind, client: Client, workspace: Option, - layout: Workspace, + layout: WorkspaceTabs, items: HashMap, sessions: HashMap, starting: bool, @@ -42,7 +43,7 @@ pub struct Space { reattaching: HashSet, restoring_sessions: HashMap, restoring_plugins: Vec, - drag_target: Option<(crate::workspace::PaneId, Option)>, + drag_target: Option<(WorkspaceTabId, crate::workspace::PaneId, Option)>, problem: Option, wakeup_tx: mpsc::UnboundedSender<()>, _wakeups: Task<()>, @@ -63,7 +64,7 @@ impl Space { kind, client, workspace: None, - layout: Workspace::new(), + layout: WorkspaceTabs::new(), items: HashMap::new(), sessions: HashMap::new(), starting: false, @@ -118,12 +119,20 @@ impl Space { self.problem.as_deref() } - pub fn layout(&self) -> &Workspace { + pub fn workspace_tabs(&self) -> &WorkspaceTabs { &self.layout } + pub fn active_tab_id(&self) -> Option { + self.layout.active_tab_id() + } + + pub fn active_layout(&self) -> Option<&Workspace> { + self.layout.active_workspace() + } + pub fn active(&self) -> Option { - self.layout.pane(self.layout.active_pane()).and_then(|pane| pane.active()) + self.layout.active_item() } pub fn item(&self, id: ItemId) -> Option<&Item> { @@ -221,7 +230,7 @@ impl Space { for item in invalid { let _ = self.layout.remove_item(item); } - let _ = self.layout.prune_empty_panes(); + let _ = self.layout.prune_empty(); } pub fn persisted(&self) -> PersistedSpace { @@ -263,25 +272,34 @@ impl Space { } pub fn activate_pane_in_direction(&mut self, direction: SplitDirection) { - self.layout.activate_pane_in_direction(direction); + if let Some(layout) = self.layout.active_workspace_mut() { + layout.activate_pane_in_direction(direction); + } } - pub fn activate_pane(&mut self, pane: crate::workspace::PaneId) { - if let Err(error) = self.layout.activate_pane(pane) { + pub fn activate_pane(&mut self, tab: WorkspaceTabId, pane: crate::workspace::PaneId) { + let result = self + .layout + .activate_tab(tab) + .and_then(|()| self.layout.workspace_mut(tab).expect("known tab").activate_pane(pane)); + if let Err(error) = result { self.problem = Some(error.to_string()); } } - pub fn drag_target(&self) -> Option<(crate::workspace::PaneId, Option)> { + pub fn drag_target( + &self, + ) -> Option<(WorkspaceTabId, crate::workspace::PaneId, Option)> { self.drag_target } pub fn set_drag_target( &mut self, + tab: WorkspaceTabId, pane: crate::workspace::PaneId, direction: Option, ) -> bool { - let target = Some((pane, direction)); + let target = Some((tab, pane, direction)); if self.drag_target == target { return false; } @@ -293,8 +311,19 @@ impl Space { self.drag_target.take().is_some() } - pub fn resize_divider(&mut self, axis_path: &[usize], divider: usize, fraction: f32) { - if let Err(error) = self.layout.center.resize_divider(axis_path, divider, fraction) { + pub fn resize_divider( + &mut self, + tab: WorkspaceTabId, + axis_path: &[usize], + divider: usize, + fraction: f32, + ) { + let result = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.center.resize_divider(axis_path, divider, fraction)); + if let Err(error) = result { self.problem = Some(error.to_string()); } } @@ -302,45 +331,60 @@ impl Space { pub fn drop_item( &mut self, item: ItemId, - source: crate::workspace::PaneId, - target: crate::workspace::PaneId, + source_tab: WorkspaceTabId, + source_pane: crate::workspace::PaneId, + target_tab: WorkspaceTabId, + target_pane: crate::workspace::PaneId, index: Option, ) { - if self.layout.pane_for_item(item) != Some(source) { + if self.layout.location(item) != Some((source_tab, source_pane)) { self.drag_target = None; return; } let direction = self .drag_target - .filter(|(pane, _)| *pane == target) - .and_then(|(_, direction)| direction); + .filter(|(tab, pane, _)| *tab == target_tab && *pane == target_pane) + .and_then(|(_, _, direction)| direction); self.drag_target = None; let destination = match direction { - Some(direction) => match self.layout.split_pane(target, direction) { + Some(direction) => match self + .layout + .workspace_mut(target_tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(target_tab)) + .and_then(|layout| layout.split_pane(target_pane, direction)) + { Ok(pane) => pane, Err(error) => { self.problem = Some(error.to_string()); return; } }, - None => target, + None => target_pane, }; - if let Err(error) = self.layout.move_item(item, destination, index) { + if let Err(error) = + self.layout.move_item(item, source_tab, source_pane, target_tab, destination, index) + { self.problem = Some(error.to_string()); } } pub fn prepare_drop_destination( &mut self, + tab: WorkspaceTabId, target: crate::workspace::PaneId, ) -> Option { let direction = self .drag_target .take() - .filter(|(pane, _)| *pane == target) - .and_then(|(_, direction)| direction); + .filter(|(candidate, pane, _)| *candidate == tab && *pane == target) + .and_then(|(_, _, direction)| direction); match direction { - Some(direction) => match self.layout.split_pane(target, direction) { + Some(direction) => match self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.split_pane(target, direction)) + { Ok(pane) => Some(pane), Err(error) => { self.problem = Some(error.to_string()); @@ -354,113 +398,143 @@ impl Space { /// Zed's split-and-move action creates the neighboring pane and moves the /// active item into it. Items remain unique; terminals are never cloned. pub fn split_and_move(&mut self, direction: SplitDirection) { - let source = self.layout.active_pane(); - self.split_and_move_in(source, direction); + let Some(tab) = self.layout.active_tab_id() else { + return; + }; + let source = self.layout.workspace(tab).expect("active tab").active_pane(); + self.split_and_move_in(tab, source, direction); } pub fn split_and_move_in( &mut self, + tab: WorkspaceTabId, source: crate::workspace::PaneId, direction: SplitDirection, ) { - if let Err(error) = self.layout.split_and_move(source, direction) { + let result = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.split_and_move(source, direction)); + if let Err(error) = result { self.problem = Some(error.to_string()); + } else { + let _ = self.layout.activate_tab(tab); } } - pub fn remove_empty_pane(&mut self, pane: crate::workspace::PaneId) { - if let Err(error) = self.layout.remove_empty_pane(pane) { + pub fn remove_empty_pane(&mut self, tab: WorkspaceTabId, pane: crate::workspace::PaneId) { + let result = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.remove_empty_pane(pane)); + if let Err(error) = result { self.problem = Some(error.to_string()); } } pub fn move_active_to_pane(&mut self, direction: SplitDirection) { - let source = self.layout.active_pane(); - let active = self.layout.pane(source).and_then(|pane| pane.active()); - let destination = self.layout.pane_in_direction(direction); + let Some(layout) = self.layout.active_workspace_mut() else { + return; + }; + let source = layout.active_pane(); + let active = layout.pane(source).and_then(|pane| pane.active()); + let destination = layout.pane_in_direction(direction); if let (Some(active), Some(destination)) = (active, destination) - && let Err(error) = self.layout.move_item(active, destination, None) + && let Err(error) = layout.move_item(active, destination, None) { self.problem = Some(error.to_string()); } } pub fn join_active_into_next(&mut self) { - let source = self.layout.active_pane(); + let Some(layout) = self.layout.active_workspace_mut() else { + return; + }; + let source = layout.active_pane(); let destination = [SplitDirection::Right, SplitDirection::Down, SplitDirection::Left, SplitDirection::Up] .into_iter() - .find_map(|direction| self.layout.pane_in_direction(direction)); + .find_map(|direction| layout.pane_in_direction(direction)); if let Some(destination) = destination - && let Err(error) = self.layout.join_pane(source, destination) + && let Err(error) = layout.join_pane(source, destination) { self.problem = Some(error.to_string()); } } pub fn toggle_zoom(&mut self) { - let active = self.layout.active_pane(); - if let Err(error) = self.layout.center.toggle_maximized(active) { + let Some(tab) = self.layout.active_tab_id() else { + return; + }; + let active = self.layout.workspace(tab).expect("active tab").active_pane(); + self.toggle_zoom_in(tab, active); + } + + pub fn toggle_zoom_in(&mut self, tab: WorkspaceTabId, pane: crate::workspace::PaneId) { + let result = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.center.toggle_maximized(pane)); + if let Err(error) = result { self.problem = Some(error.to_string()); + } else { + let _ = self.layout.activate_tab(tab); } } pub fn entries(&self, space: gpui::EntityId) -> Vec { self.layout - .panes() - .flat_map(|pane| { - pane.items().iter().enumerate().filter_map(move |(index, id)| { - let item = self.items.get(id)?; - Some(Entry { - space, - space_key: self.key(), - key: *id, - pane: pane.id, - index, - title: item.title(), - agent: item.agent(), - ended: item.ended(), - selected: pane.active() == Some(*id) - && self.layout.active_pane() == pane.id, - closable: true, - }) + .tabs() + .iter() + .filter_map(|tab| { + let id = tab.active_item()?; + let pane = tab.layout.pane_for_item(id)?; + let index = + tab.layout.pane(pane)?.items().iter().position(|candidate| *candidate == id)?; + let item = self.items.get(&id)?; + let grouped = tab.is_grouped(); + Some(Entry { + space, + space_key: self.key(), + key: id, + tab: tab.id, + pane, + index, + title: if grouped { "Grouped Tabs".to_owned() } else { item.title() }, + agent: item.agent(), + ended: item.ended(), + selected: self.layout.active_tab_id() == Some(tab.id), + closable: true, + grouped, + item_count: tab.layout.item_count(), }) }) .collect() } - pub fn pane_entries(&self, space: gpui::EntityId) -> Vec { + pub fn pane_item_ids( + &self, + tab: WorkspaceTabId, + pane: crate::workspace::PaneId, + ) -> Vec { self.layout - .panes() - .map(|pane| PaneEntries { - id: pane.id, - entries: pane - .items() - .iter() - .enumerate() - .filter_map(|(index, id)| { - let item = self.items.get(id)?; - Some(Entry { - space, - space_key: self.key(), - key: *id, - pane: pane.id, - index, - title: item.title(), - agent: item.agent(), - ended: item.ended(), - selected: pane.active() == Some(*id) - && self.layout.active_pane() == pane.id, - closable: true, - }) - }) - .collect(), - }) - .collect() + .workspace(tab) + .and_then(|layout| layout.pane(pane)) + .map(|pane| pane.items().to_vec()) + .unwrap_or_default() } - pub fn pane_item_ids(&self, pane: crate::workspace::PaneId) -> Vec { - self.layout.pane(pane).map(|pane| pane.items().to_vec()).unwrap_or_default() + pub fn tab_item_ids(&self, tab: WorkspaceTabId) -> Vec { + self.layout.workspace(tab).map(|layout| layout.item_ids().collect()).unwrap_or_default() + } + + pub fn move_workspace_tab(&mut self, tab: WorkspaceTabId, target_index: usize) { + if let Err(error) = self.layout.move_tab(tab, target_index) { + self.problem = Some(error.to_string()); + } } pub fn all_item_ids(&self) -> Vec { @@ -503,7 +577,7 @@ impl Space { Action::Close { item, .. } => self.close_item(item, cx), Action::New | Action::NewInSpace { .. } - | Action::MoveItem { .. } + | Action::MoveWorkspaceTab { .. } | Action::CloseGroup { .. } | Action::CloseSpace { .. } | Action::RenameSpace { .. } @@ -523,15 +597,23 @@ impl Space { pub fn open_plugin_in( &mut self, plugin: PluginItem, + tab: WorkspaceTabId, pane: crate::workspace::PaneId, index: Option, cx: &mut Context, ) -> ItemId { let id = self.layout.alloc_item(); self.items.insert(id, Item::Plugin(plugin)); - if let Err(error) = self.layout.add_item(id, Some(pane), index) { + let result = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.add_item(id, Some(pane), index)); + if let Err(error) = result { self.items.remove(&id); self.problem = Some(error.to_string()); + } else { + let _ = self.layout.activate_tab(tab); } cx.notify(); id @@ -549,8 +631,8 @@ impl Space { pub fn open_plugin_at(&mut self, id: ItemId, plugin: PluginItem, cx: &mut Context) { self.items.insert(id, Item::Plugin(plugin)); - if self.layout.pane_for_item(id).is_none() - && let Err(error) = self.layout.add_item(id, None, None) + if self.layout.location(id).is_none() + && let Err(error) = self.layout.push_standalone(id) { self.items.remove(&id); self.problem = Some(error.to_string()); @@ -641,8 +723,17 @@ impl Space { /// Attach sessions discovered by the parent's one backend snapshot. /// Process spawning and stream setup stay off the frame thread. pub fn adopt(&mut self, infos: Vec, cx: &mut Context) { - let infos: Vec<_> = - infos.into_iter().filter(|info| !self.sessions.contains_key(&info.id)).collect(); + let mut discovered = Vec::new(); + for info in infos { + if let Some(item) = self.sessions.get(&info.id).copied() { + if let Some(session) = self.items.get_mut(&item).and_then(Item::as_session_mut) { + session.session.info = info; + } + } else { + discovered.push(info); + } + } + let infos = discovered; if infos.is_empty() { return; } @@ -751,8 +842,8 @@ impl Space { } let id = restored.unwrap_or_else(|| self.layout.alloc_item()); self.items.insert(id, Item::Session(SessionItem::new(session))); - if self.layout.pane_for_item(id).is_none() - && let Err(error) = self.layout.add_item(id, None, None) + if self.layout.location(id).is_none() + && let Err(error) = self.layout.push_standalone(id) { self.items.remove(&id); self.problem = Some(error.to_string()); diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index 4073d387..885d1755 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -6,12 +6,9 @@ //! invariant observable at one seam: an item belongs to exactly one pane in //! exactly one workspace. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; -#[cfg(test)] -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct PaneId(u64); @@ -31,6 +28,18 @@ impl ItemId { } } +/// One entry in a space's outer tab strip. A workspace tab may be a standalone +/// item or a pane group; that distinction is derived from its contents rather +/// than stored as a second source of truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct WorkspaceTabId(u64); + +impl WorkspaceTabId { + pub fn get(self) -> u64 { + self.0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Axis { @@ -504,10 +513,6 @@ impl Workspace { self.panes.get(&id) } - pub fn panes(&self) -> impl Iterator { - self.center.panes().into_iter().filter_map(|id| self.panes.get(&id)) - } - pub fn pane_for_item(&self, item: ItemId) -> Option { self.panes_by_item.get(&item).copied() } @@ -516,6 +521,18 @@ impl Workspace { self.panes_by_item.keys().copied() } + pub fn item_count(&self) -> usize { + self.panes_by_item.len() + } + + pub fn is_empty(&self) -> bool { + self.panes_by_item.is_empty() + } + + pub fn is_grouped(&self) -> bool { + self.item_count() > 1 || self.center.panes().len() > 1 + } + pub fn activate_pane(&mut self, pane: PaneId) -> Result<(), ModelError> { if !self.center.contains(pane) { return Err(ModelError::PaneNotFound(pane)); @@ -534,6 +551,7 @@ impl Workspace { self.center.pane_in_direction(self.active_pane, direction) } + #[cfg(test)] pub fn alloc_item(&mut self) -> ItemId { let id = ItemId(self.next_item_id); self.next_item_id += 1; @@ -770,11 +788,347 @@ impl Workspace { } } +/// One outer tab and the Zed-style pane workspace shown when it is active. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceTab { + pub id: WorkspaceTabId, + pub layout: Workspace, +} + +impl WorkspaceTab { + pub fn is_grouped(&self) -> bool { + self.layout.is_grouped() + } + + pub fn active_item(&self) -> Option { + self.layout.pane(self.layout.active_pane()).and_then(Pane::active) + } +} + +/// The outer tab collection for one Chartr space. +/// +/// Zed's pane model remains intact inside each [`WorkspaceTab`]. This layer is +/// Chartr's presentation model: a one-item tab is standalone, while a tab with +/// multiple items or panes is presented as one grouped entry. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkspaceTabs { + tabs: Vec, + active: Option, + activation_history: Vec, + next_tab_id: u64, + next_item_id: u64, +} + +#[derive(Deserialize)] +struct WorkspaceTabsFields { + #[serde(default)] + tabs: Vec, + #[serde(default)] + active: Option, + #[serde(default)] + activation_history: Vec, + #[serde(default)] + next_tab_id: u64, + #[serde(default)] + next_item_id: u64, + #[serde(default)] + center: Option, + #[serde(default)] + panes: BTreeMap, + #[serde(default)] + panes_by_item: HashMap, + #[serde(default)] + active_pane: Option, + #[serde(default)] + next_pane_id: u64, +} + +impl<'de> Deserialize<'de> for WorkspaceTabs { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let fields = WorkspaceTabsFields::deserialize(deserializer)?; + let mut tabs = if let Some(center) = fields.center { + let layout = Workspace { + center, + panes: fields.panes, + panes_by_item: fields.panes_by_item, + active_pane: fields.active_pane.unwrap_or(PaneId(1)), + next_pane_id: fields.next_pane_id, + next_item_id: fields.next_item_id, + }; + if layout.is_empty() { + Self::new() + } else { + let id = WorkspaceTabId(1); + Self { + tabs: vec![WorkspaceTab { id, layout }], + active: Some(id), + activation_history: vec![id], + next_tab_id: 2, + next_item_id: 1, + } + } + } else { + Self { + tabs: fields.tabs, + active: fields.active, + activation_history: fields.activation_history, + next_tab_id: fields.next_tab_id, + next_item_id: fields.next_item_id, + } + }; + tabs.normalize(); + Ok(tabs) + } +} + +impl Default for WorkspaceTabs { + fn default() -> Self { + Self::new() + } +} + +impl WorkspaceTabs { + pub fn new() -> Self { + Self { + tabs: Vec::new(), + active: None, + activation_history: Vec::new(), + next_tab_id: 1, + next_item_id: 1, + } + } + + fn normalize(&mut self) { + let known: HashSet<_> = self.tabs.iter().map(|tab| tab.id).collect(); + self.activation_history.retain(|tab| known.contains(tab)); + if self.active.is_none_or(|active| !known.contains(&active)) { + self.active = self.tabs.last().map(|tab| tab.id); + } + if let Some(active) = self.active { + self.activation_history.retain(|tab| *tab != active); + self.activation_history.push(active); + } + self.next_tab_id = + self.next_tab_id.max(self.tabs.iter().map(|tab| tab.id.0 + 1).max().unwrap_or(1)); + self.next_item_id = + self.next_item_id.max(self.item_ids().map(|item| item.0 + 1).max().unwrap_or(1)); + } + + pub fn tabs(&self) -> &[WorkspaceTab] { + &self.tabs + } + + pub fn active_tab_id(&self) -> Option { + self.active + } + + pub fn active_tab(&self) -> Option<&WorkspaceTab> { + self.active.and_then(|active| self.tab(active)) + } + + pub fn active_workspace(&self) -> Option<&Workspace> { + self.active_tab().map(|tab| &tab.layout) + } + + pub fn active_workspace_mut(&mut self) -> Option<&mut Workspace> { + let active = self.active?; + self.tab_mut(active).map(|tab| &mut tab.layout) + } + + pub fn tab(&self, id: WorkspaceTabId) -> Option<&WorkspaceTab> { + self.tabs.iter().find(|tab| tab.id == id) + } + + pub fn tab_mut(&mut self, id: WorkspaceTabId) -> Option<&mut WorkspaceTab> { + self.tabs.iter_mut().find(|tab| tab.id == id) + } + + pub fn workspace(&self, id: WorkspaceTabId) -> Option<&Workspace> { + self.tab(id).map(|tab| &tab.layout) + } + + pub fn workspace_mut(&mut self, id: WorkspaceTabId) -> Option<&mut Workspace> { + self.tab_mut(id).map(|tab| &mut tab.layout) + } + + pub fn active_item(&self) -> Option { + self.active_tab().and_then(WorkspaceTab::active_item) + } + + pub fn alloc_item(&mut self) -> ItemId { + let id = ItemId(self.next_item_id); + self.next_item_id += 1; + id + } + + pub fn item_ids(&self) -> impl Iterator + '_ { + self.tabs.iter().flat_map(|tab| tab.layout.item_ids()) + } + + pub fn location(&self, item: ItemId) -> Option<(WorkspaceTabId, PaneId)> { + self.tabs.iter().find_map(|tab| tab.layout.pane_for_item(item).map(|pane| (tab.id, pane))) + } + + pub fn activate_tab(&mut self, id: WorkspaceTabId) -> Result<(), ModelError> { + if self.tab(id).is_none() { + return Err(ModelError::WorkspaceTabNotFound(id)); + } + self.active = Some(id); + self.activation_history.retain(|tab| *tab != id); + self.activation_history.push(id); + Ok(()) + } + + pub fn activate_item(&mut self, item: ItemId) -> Result<(), ModelError> { + let (tab, _) = self.location(item).ok_or(ModelError::ItemNotFound(item))?; + self.workspace_mut(tab).expect("known workspace tab").activate_item(item)?; + self.activate_tab(tab) + } + + pub fn push_standalone(&mut self, item: ItemId) -> Result { + self.push_standalone_at(item, self.tabs.len()) + } + + pub fn push_standalone_at( + &mut self, + item: ItemId, + index: usize, + ) -> Result { + if self.location(item).is_some() { + return Err(ModelError::DuplicateItem(item)); + } + let id = WorkspaceTabId(self.next_tab_id); + self.next_tab_id += 1; + let mut layout = Workspace::new(); + layout.add_item(item, None, None)?; + self.tabs.insert(index.min(self.tabs.len()), WorkspaceTab { id, layout }); + self.activate_tab(id)?; + Ok(id) + } + + pub fn move_tab(&mut self, tab: WorkspaceTabId, destination: usize) -> Result<(), ModelError> { + let source = self + .tabs + .iter() + .position(|candidate| candidate.id == tab) + .ok_or(ModelError::WorkspaceTabNotFound(tab))?; + let tab = self.tabs.remove(source); + self.tabs.insert(destination.min(self.tabs.len()), tab); + Ok(()) + } + + pub fn remove_item(&mut self, item: ItemId) -> Result<(), ModelError> { + let (tab, _) = self.location(item).ok_or(ModelError::ItemNotFound(item))?; + self.workspace_mut(tab).expect("known workspace tab").remove_item(item)?; + self.remove_tab_if_empty(tab); + Ok(()) + } + + pub fn move_item( + &mut self, + item: ItemId, + source_tab: WorkspaceTabId, + source_pane: PaneId, + target_tab: WorkspaceTabId, + target_pane: PaneId, + destination_index: Option, + ) -> Result<(), ModelError> { + if self.location(item) != Some((source_tab, source_pane)) { + return Err(ModelError::ItemNotFound(item)); + } + if self.workspace(target_tab).and_then(|layout| layout.pane(target_pane)).is_none() { + return Err(ModelError::PaneNotFound(target_pane)); + } + if source_tab == target_tab { + self.workspace_mut(target_tab).expect("known workspace tab").move_item( + item, + target_pane, + destination_index, + )?; + } else { + self.workspace_mut(source_tab) + .expect("known source workspace tab") + .remove_item(item)?; + self.workspace_mut(target_tab).expect("known target workspace tab").add_item( + item, + Some(target_pane), + destination_index, + )?; + self.remove_tab_if_empty(source_tab); + } + self.activate_tab(target_tab)?; + Ok(()) + } + + pub fn prune_empty(&mut self) -> Result<(), ModelError> { + for tab in &mut self.tabs { + tab.layout.prune_empty_panes()?; + } + let empty: Vec<_> = + self.tabs.iter().filter(|tab| tab.layout.is_empty()).map(|tab| tab.id).collect(); + for tab in empty { + self.remove_tab_if_empty(tab); + } + Ok(()) + } + + fn remove_tab_if_empty(&mut self, id: WorkspaceTabId) { + let Some(index) = self.tabs.iter().position(|tab| tab.id == id && tab.layout.is_empty()) + else { + return; + }; + self.tabs.remove(index); + self.activation_history.retain(|tab| *tab != id); + if self.active == Some(id) { + self.active = self + .activation_history + .iter() + .rev() + .find(|candidate| self.tabs.iter().any(|tab| tab.id == **candidate)) + .copied() + .or_else(|| { + self.tabs.get(index.min(self.tabs.len().saturating_sub(1))).map(|tab| tab.id) + }); + if let Some(active) = self.active { + self.activation_history.retain(|tab| *tab != active); + self.activation_history.push(active); + } + } + } + + #[cfg(test)] + pub fn validate(&self) -> Result<(), ModelError> { + let mut tabs = HashSet::new(); + let mut items = HashSet::new(); + for tab in &self.tabs { + if !tabs.insert(tab.id) || tab.layout.is_empty() { + return Err(ModelError::InvalidWorkspaceTabs); + } + tab.layout.validate()?; + for item in tab.layout.item_ids() { + if !items.insert(item) { + return Err(ModelError::DuplicateItem(item)); + } + } + } + if self.active.is_some_and(|active| !tabs.contains(&active)) { + return Err(ModelError::WorkspaceTabNotFound(self.active.expect("checked"))); + } + if self.tabs.is_empty() != self.active.is_none() { + return Err(ModelError::InvalidWorkspaceTabs); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelError { PaneNotFound(PaneId), + WorkspaceTabNotFound(WorkspaceTabId), ItemNotFound(ItemId), - #[cfg(test)] DuplicateItem(ItemId), #[cfg(test)] ItemIndexMismatch(ItemId), @@ -782,6 +1136,8 @@ pub enum ModelError { InvalidActiveItem(PaneId), #[cfg(test)] InvalidPaneTree, + #[cfg(test)] + InvalidWorkspaceTabs, BadAxisPath, InvalidFlexes, } @@ -798,6 +1154,100 @@ impl std::error::Error for ModelError {} mod tests { use super::*; + #[test] + fn workspace_tabs_mix_standalone_items_with_one_pane_group() { + let mut tabs = WorkspaceTabs::new(); + let items: Vec<_> = (0..5).map(|_| tabs.alloc_item()).collect(); + let outer: Vec<_> = items.iter().map(|item| tabs.push_standalone(*item).unwrap()).collect(); + let target_pane = tabs.workspace(outer[2]).unwrap().active_pane(); + let right = tabs + .workspace_mut(outer[2]) + .unwrap() + .split_pane(target_pane, SplitDirection::Right) + .unwrap(); + + tabs.move_item(items[3], outer[3], PaneId(1), outer[2], right, None).unwrap(); + tabs.move_item(items[4], outer[4], PaneId(1), outer[2], right, None).unwrap(); + + assert_eq!(tabs.tabs().iter().map(|tab| tab.id).collect::>(), outer[..3]); + assert!(!tabs.tab(outer[0]).unwrap().is_grouped()); + assert!(!tabs.tab(outer[1]).unwrap().is_grouped()); + assert!(tabs.tab(outer[2]).unwrap().is_grouped()); + assert_eq!(tabs.workspace(outer[2]).unwrap().pane(right).unwrap().items(), &items[3..]); + assert_eq!(tabs.active_tab_id(), Some(outer[2])); + tabs.validate().unwrap(); + let restored: WorkspaceTabs = + serde_json::from_str(&serde_json::to_string(&tabs).unwrap()).unwrap(); + assert_eq!(restored, tabs); + restored.validate().unwrap(); + } + + #[test] + fn standalone_outer_tabs_drop_into_a_group_center_or_any_edge() { + for direction in [ + None, + Some(SplitDirection::Up), + Some(SplitDirection::Right), + Some(SplitDirection::Down), + Some(SplitDirection::Left), + ] { + let mut tabs = WorkspaceTabs::new(); + let target_item = tabs.alloc_item(); + let source_item = tabs.alloc_item(); + let target_tab = tabs.push_standalone(target_item).unwrap(); + let source_tab = tabs.push_standalone(source_item).unwrap(); + let target_pane = tabs.location(target_item).unwrap().1; + let source_pane = tabs.location(source_item).unwrap().1; + let destination = direction.map_or(target_pane, |direction| { + tabs.workspace_mut(target_tab).unwrap().split_pane(target_pane, direction).unwrap() + }); + + tabs.move_item(source_item, source_tab, source_pane, target_tab, destination, None) + .unwrap(); + + assert_eq!(tabs.tabs().len(), 1, "{direction:?}"); + assert_eq!(tabs.active_tab_id(), Some(target_tab), "{direction:?}"); + assert!(tabs.tab(target_tab).unwrap().is_grouped(), "{direction:?}"); + assert_eq!(tabs.workspace(target_tab).unwrap().item_count(), 2, "{direction:?}"); + assert_eq!( + tabs.workspace(target_tab).unwrap().center.panes().len(), + direction.map_or(1, |_| 2), + "{direction:?}", + ); + tabs.validate().unwrap(); + } + } + + #[test] + fn workspace_tabs_round_trip_and_continue_allocating_unique_ids() { + let mut tabs = WorkspaceTabs::new(); + let first = tabs.alloc_item(); + let second = tabs.alloc_item(); + tabs.push_standalone(first).unwrap(); + tabs.push_standalone(second).unwrap(); + let json = serde_json::to_string(&tabs).unwrap(); + let mut restored: WorkspaceTabs = + serde_json::from_str(&json).unwrap_or_else(|error| panic!("{error}: {json}")); + + assert_eq!(restored, tabs); + assert_eq!(restored.alloc_item(), ItemId(3)); + restored.validate().unwrap(); + } + + #[test] + fn legacy_single_workspace_state_becomes_one_outer_workspace_tab() { + let mut legacy = Workspace::new(); + let item = legacy.alloc_item(); + legacy.add_item(item, None, None).unwrap(); + let json = serde_json::to_string(&legacy).unwrap(); + + let restored: WorkspaceTabs = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.tabs().len(), 1); + assert_eq!(restored.active_item(), Some(item)); + restored.validate().unwrap(); + } + #[test] fn splitting_a_lone_tab_with_two_existing_panes_matches_zeds_empty_pane_rule() { for direction in diff --git a/docs/acceptance.md b/docs/acceptance.md index 11d20c40..0d5773e7 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -25,11 +25,15 @@ Chartr Light. Capture and compare: - empty Ad-hoc startup, one folder, and several spaces; - Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; -- empty root, one pane, nested horizontal/vertical panes, resized dividers, +- empty space, one standalone tab, nested horizontal/vertical panes, resized dividers, zoom, and automatic split collapse after its last item moves or closes; -- terminal and plugin close buttons, active/hover/focus states, one collapsed - sidebar tab per multi-pane group, visible draggable tab bars in every - non-empty workspace pane, and the bulk-termination confirmation; +- terminal and plugin close buttons, active/hover/focus states, two standalone + outer tabs beside one collapsed three-item pane group in both chromes, visible + draggable tab bars in every selected group pane, and bulk confirmation scoped + to only the selected group; +- live terminal titles changing from their Herdr tab number to `nano`, `htop`, + or a detected agent and back when that foreground process exits; every + collapsed pane group remains titled `Grouped Tabs`; - Zed-style transient pane-body drop highlights: full-content center and half-content left, right, top, and bottom targets, including nearest-edge corner resolution and no split target over a pane's tab bar; @@ -64,6 +68,13 @@ items; tab headers must remain visible and draggable throughout. Clicking inside a web plugin must activate its pane, and its native child view must yield during a drag so neither the tab preview nor drop highlight is obscured. +Create five standalone tabs in one space. Move tabs 4 and 5 into tab 3, split +tab 4 to the right, and leave tabs 1 and 2 standalone. Both sidebar and tabbed +chrome must show exactly three outer entries: tab 1, tab 2, and one three-item +group. While that group is selected, drag either standalone outer entry into +the center and each of the four edges of every pane. Confirm the source outer +entry disappears, the target group remains selected, and no item is duplicated. + Inspect the GPUI accessibility tree on macOS and Linux. Tabs and Settings navigation must expose roles, labels, and selection; Zed buttons and menus must retain their labels and focus rings; contrast must remain readable in both @@ -73,7 +84,7 @@ alternate transition path. ## Persistence and lifecycle Relaunch after changing window bounds, sidebar width/scope, mode, space names, -split ratios, active panes/items, plugin Settings, and a missing folder. Confirm +outer-tab order, split ratios, active groups/panes/items, plugin Settings, and a missing folder. Confirm normal exit adopts detached terminals; item close kills exactly one session; closing a populated pane or folder space confirms and kills all descendants; session-bound plugins cascade; disabling or revoking a plugin closes every live diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md index 57dec89b..1541c644 100644 --- a/docs/adr/0005-spaces-follow-zed-multi-workspace.md +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -7,10 +7,13 @@ sidebar chrome can present either the active space or every space at once. The ownership shape follows the pinned Zed revision's `workspace::MultiWorkspace`: - the root owns an ordered `Vec>` and the active entity; -- each `Space` owns its pane tree, items, session collection, and active item; +- each `Space` owns an ordered outer workspace-tab collection, items, session + collection, and active outer tab; +- each outer workspace tab owns one Zed-style pane tree and is presented as a + standalone tab when it has one item or a grouped tab when it has several; - the root observes every child and partitions backend snapshots between them; -- session actions carry stable pane ids, not positions in the currently drawn - list; and +- session actions carry stable outer-tab and pane ids, not positions in the + currently drawn list; and - blocking backend calls run on GPUI's background executor and return owned answers to the entity context. @@ -23,8 +26,9 @@ node-runtime systems that zeddy does not use. The folder registry lives at `$XDG_CONFIG_HOME/chartr-zeddy/spaces.toml`, with platform fallbacks, file order as display order, duplicate suppression, and -unknown TOML keys preserved. Window bounds, chrome choice, pane trees, item -ownership, and restorable plugin state live in Chartr's SQLite state store. The +unknown TOML keys preserved. Window bounds, chrome choice, ordered outer tabs, +pane trees, item ownership, and restorable plugin state live in Chartr's SQLite +state store. A pre-outer-tab pane tree migrates to one grouped outer entry. The rewrite deliberately does not import or mutate older Chartr registries. Ad-hoc sessions are the one synthetic space. They use the operator's home @@ -34,14 +38,25 @@ one backend workspace would pretend to be independent state when they are not. ## Chrome -The sketches choose where items appear: grouped vertically in sidebar mode and -horizontally for the active space in tabs mode. Both reuse Zed `ui` components -for tabs, buttons, labels, icons, colors, focus tracking, and scroll containers. -There is no custom popup, menu state machine, or parallel widget kit. +Both chromes project the same outer collection: every standalone item and every +pane group is one entry. Sidebar mode draws those entries beneath each visible +space; tabbed mode draws the active space's entries beside its name. Selecting a +group reveals the pane-local Zed tab bars, while a standalone item has no +duplicate inner bar. Both reuse Zed `ui` components for tabs, buttons, labels, +icons, colors, focus tracking, and scroll containers. There is no custom popup, +menu state machine, or parallel widget kit. + +Standalone terminal labels are live backend presentation: detected agent, +non-shell foreground process, then Herdr's persistent tab label or number. They +are refreshed on the same two-second cadence as session discovery and are not +persisted locally. A collapsed pane group is deliberately just `Grouped Tabs`; +its children retain their individual live labels in the pane-local tab bars. ## Consequence Switching spaces is an entity-selection change. It cannot reparent a session, -reuse another space's selected index, or recreate backend work. Adding a third -chrome arrangement likewise cannot change the space model: it can only draw -the active child's entries somewhere else. +reuse another space's selected index, or recreate backend work. Moving a +standalone outer tab into a selected pane changes ownership once and removes its +emptied outer entry; changing chrome never does. Adding a third chrome +arrangement likewise cannot change the space model: it can only draw the active +child's entries somewhere else. From debf2f510ccadcb0301f18c3c92cc27c75059ab9 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 03:38:10 +0800 Subject: [PATCH 006/110] Restore session status chrome and empty pane controls --- crates/zeddy-herdr/src/control.rs | 93 ++++++++++++++++++++++++++---- crates/zeddy-herdr/src/protocol.rs | 21 +++++++ crates/zeddy/src/app.rs | 90 +++++++++++++++++++++++++++-- crates/zeddy/src/chrome.rs | 73 ++++++++++++++++++----- crates/zeddy/src/chrome/sidebar.rs | 19 +++--- crates/zeddy/src/chrome/tabs.rs | 15 ++++- crates/zeddy/src/item.rs | 8 ++- crates/zeddy/src/session.rs | 2 +- crates/zeddy/src/space.rs | 7 ++- crates/zeddy/src/web_plugin.rs | 2 +- crates/zeddy/src/workspace.rs | 42 ++++++++++++++ 11 files changed, 322 insertions(+), 50 deletions(-) diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs index 719d3df7..0378ef5c 100644 --- a/crates/zeddy-herdr/src/control.rs +++ b/crates/zeddy-herdr/src/control.rs @@ -29,14 +29,43 @@ use crate::{ stream::Attachment, }; +/// What an agent in a session is doing, once Herdr's vocabulary has been left +/// behind. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SessionStatus { + Idle, + Working, + Blocked, + Done, + /// No agent, or nothing known about one. The ordinary state of a shell. + #[default] + Unknown, +} + +impl From for SessionStatus { + fn from(status: protocol::AgentStatus) -> Self { + match status { + protocol::AgentStatus::Idle => Self::Idle, + protocol::AgentStatus::Working => Self::Working, + protocol::AgentStatus::Blocked => Self::Blocked, + protocol::AgentStatus::Done => Self::Done, + protocol::AgentStatus::Unknown => Self::Unknown, + } + } +} + /// A pane as zeddy talks about it, once herdr's vocabulary has been left behind. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { pub id: PaneId, pub workspace: WorkspaceId, - /// What to put on the tab: detected agent, non-shell foreground process, - /// persistent Herdr tab label/number, then the pane id as the last resort. - pub title: String, + /// Herdr's persistent tab label/number, used when nothing is running. + pub label: String, + /// The detected agent or non-shell foreground process, if one is running. + pub running: Option, + /// What the detected agent is doing. Ordinary processes remain Unknown; + /// their presence is carried separately by `running`. + pub status: SessionStatus, /// The agent herdr believes is running in the pane, if any. pub agent: Option, pub cwd: Option, @@ -44,8 +73,7 @@ pub struct Session { impl Session { fn from_pane(pane: protocol::Pane, label: Option, running: Option) -> Self { - let title = running - .or(label) + let label = label .or_else(|| pane.title.as_deref().and_then(non_blank).map(str::to_owned)) .unwrap_or_else(|| pane.pane_id.clone()); let agent = pane @@ -57,11 +85,24 @@ impl Session { Self { id: PaneId(pane.pane_id), workspace: WorkspaceId(pane.workspace_id), - title, + label, + running, + status: pane.agent_status.into(), agent, cwd: pane.cwd.map(PathBuf::from), } } + + /// What the tab says: the live agent/process name, or its persistent label. + pub fn title(&self) -> &str { + self.running.as_deref().unwrap_or(&self.label) + } + + /// Whether an ordinary (non-agent) process currently owns the PTY's + /// foreground process group. + pub fn process_running(&self) -> bool { + self.agent.is_none() && self.running.is_some() + } } impl From for Session { @@ -450,21 +491,39 @@ mod tests { title: title.map(str::to_owned), display_agent: agent.map(str::to_owned), agent: None, + agent_status: protocol::AgentStatus::Unknown, cwd: None, } } #[test] fn a_tab_prefers_an_agent_then_falls_back_to_title_and_id() { - assert_eq!(Session::from(pane("p1", Some("build"), Some("claude"))).title, "claude"); - assert_eq!(Session::from(pane("p1", None, Some("claude"))).title, "claude"); - assert_eq!(Session::from(pane("p1", Some("build"), None)).title, "build"); - assert_eq!(Session::from(pane("p1", None, None)).title, "p1"); + assert_eq!(Session::from(pane("p1", Some("build"), Some("claude"))).title(), "claude"); + assert_eq!(Session::from(pane("p1", None, Some("claude"))).title(), "claude"); + assert_eq!(Session::from(pane("p1", Some("build"), None)).title(), "build"); + assert_eq!(Session::from(pane("p1", None, None)).title(), "p1"); } #[test] fn a_blank_title_is_not_a_title() { - assert_eq!(Session::from(pane("p1", Some(" "), Some("codex"))).title, "codex"); + assert_eq!(Session::from(pane("p1", Some(" "), Some("codex"))).title(), "codex"); + } + + #[test] + fn herdr_agent_status_is_translated_without_inference() { + let mut info = pane("p1", None, Some("claude")); + info.agent_status = protocol::AgentStatus::Blocked; + assert_eq!(Session::from(info).status, SessionStatus::Blocked); + } + + #[test] + fn an_unknown_future_agent_status_degrades_to_unknown() { + let pane: protocol::Pane = serde_json::from_value(serde_json::json!({ + "pane_id": "p1", + "agent_status": "meditating" + })) + .expect("pane"); + assert_eq!(pane.agent_status, protocol::AgentStatus::Unknown); } #[test] @@ -496,6 +555,18 @@ mod tests { ); } + #[test] + fn an_ordinary_foreground_process_is_distinct_from_an_agent() { + let session = Session::from_pane( + pane("p1", None, None), + Some("1".to_owned()), + Some("htop".to_owned()), + ); + assert_eq!(session.title(), "htop"); + assert!(session.process_running()); + assert_eq!(session.status, SessionStatus::Unknown); + } + #[test] fn a_missing_socket_names_the_socket_it_looked_for() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/zeddy-herdr/src/protocol.rs b/crates/zeddy-herdr/src/protocol.rs index afa31103..7dd29eaa 100644 --- a/crates/zeddy-herdr/src/protocol.rs +++ b/crates/zeddy-herdr/src/protocol.rs @@ -84,10 +84,31 @@ pub struct Pane { /// Herdr's internal agent name, used only when it has no display name. #[serde(default)] pub agent: Option, + /// What Herdr believes the detected agent is doing. Chartr consumes this + /// instead of trying to infer agent state from terminal output itself. + #[serde(default)] + pub agent_status: AgentStatus, #[serde(default)] pub cwd: Option, } +/// What Herdr believes the agent in a pane is doing. +/// +/// Unknown future values deliberately become [`Unknown`](Self::Unknown): a +/// newer daemon gaining another state must not make an older Chartr unable to +/// list or attach the pane. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Idle, + Working, + Blocked, + Done, + #[default] + #[serde(other)] + Unknown, +} + /// A Herdr tab: the persistent name and ordering container for one Chartr /// terminal session. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 9f6a18f3..0c290963 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -18,8 +18,9 @@ use gpui::{ Role, }; use ui::{ - Banner, ContextMenu, DropdownMenu, DropdownStyle, IconPosition, ListItem, ListItemSpacing, - PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, prelude::*, + Banner, ButtonSize, ContextMenu, DropdownMenu, DropdownStyle, IconButtonShape, IconPosition, + ListItem, ListItemSpacing, PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, + prelude::*, }; use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; use zeddy_plugin::{InstanceContext, manifest::Multiplicity}; @@ -862,10 +863,25 @@ impl Zeddy { let Some(space) = self.active.clone() else { return; }; - let active = space.read(cx).active(); + let (active, empty_pane) = space.read_with(cx, |space, _| { + let active = space.active(); + let empty_pane = if active.is_none() { + space.active_tab_id().and_then(|tab| { + let layout = space.active_layout()?; + let pane = layout.active_pane(); + layout.pane(pane)?.items().is_empty().then_some((tab, pane)) + }) + } else { + None + }; + (active, empty_pane) + }); if let Some(active) = active { space .update(cx, |space, cx| space.act(Action::Close { space: None, item: active }, cx)); + } else if let Some((tab, pane)) = empty_pane { + space.update(cx, |space, _| space.remove_empty_pane(tab, pane)); + cx.notify(); } } @@ -2294,8 +2310,13 @@ impl Zeddy { return message("Pane layout is unavailable.", cx).into_any_element(); }; let active_pane = layout.active_pane() == pane_id; - let header = (show_header && pane.active().is_some()) - .then(|| self.pane_header(space, tab_id, layout, pane_id, on, weak, cx)); + let header = show_header.then(|| { + if pane.active().is_some() { + self.pane_header(space, tab_id, layout, pane_id, on, weak, cx) + } else { + self.empty_pane_header(tab_id, pane_id, weak) + } + }); let content = pane .active() .and_then(|id| space.item(id).map(|item| (id, item))) @@ -2364,7 +2385,7 @@ impl Zeddy { crate::item::Item::Plugin(item) => item.view.clone().into_any_element(), }) .unwrap_or_else(|| { - message("Drop a tab here or create a new item.", cx).into_any_element() + empty_pane_message("Drop a tab here or create a new item.", cx).into_any_element() }); let drag_move = weak.clone(); @@ -2388,6 +2409,9 @@ impl Zeddy { .min_w_0() .min_h_0() .bg(cx.theme().colors().editor_background) + .when(pane.active().is_none() && active_pane, |pane| { + pane.role(Role::Group).aria_label("Empty pane").tab_group().tab_index(0) + }) .capture_any_mouse_down(move |_, window, cx| { let _ = focus_pane.update(cx, |this, cx| { if let Some(space) = this.active.clone() { @@ -2450,6 +2474,37 @@ impl Zeddy { .into_any_element() } + fn empty_pane_header( + &self, + tab_id: WorkspaceTabId, + pane_id: LayoutPaneId, + weak: &gpui::WeakEntity, + ) -> AnyElement { + let close = weak.clone(); + TabBar::new(format!("workspace-tab-{}-pane-{}-empty", tab_id.get(), pane_id.get())) + .end_child( + IconButton::new( + format!("close-empty-pane-{}-{}", tab_id.get(), pane_id.get()), + IconName::Close, + ) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .aria_label("Close Empty Pane") + .tooltip(Tooltip::text("Close Empty Pane")) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + let _ = close.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| space.remove_empty_pane(tab_id, pane_id)); + } + cx.notify(); + }); + }), + ) + .into_any_element() + } + fn pane_header( &self, space: &Space, @@ -2470,6 +2525,9 @@ impl Zeddy { let tabs = pane.items().iter().enumerate().filter_map(|(index, id)| { let item = space.item(*id)?; let selected = pane.active() == Some(*id); + let status = item.status(); + let process_running = item.process_running(); + let ended = item.ended(); let position = if index == 0 { TabPosition::First } else if index + 1 == pane.items().len() { @@ -2531,11 +2589,21 @@ impl Zeddy { ); }); }) + .start_slot(chrome::status_indicator( + status, + process_running, + ended, + &space_key, + *id, + cx, + )) .end_slot( IconButton::new( format!("close-pane-{}-item-{}", pane_id.get(), id.get()), IconName::Close, ) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) .icon_size(IconSize::XSmall) .tooltip(Tooltip::text("Close")) .on_click(move |_, window, cx| { @@ -3763,6 +3831,16 @@ fn message(text: &str, cx: &App) -> impl IntoElement { .bg(cx.theme().colors().editor_background) } +fn empty_pane_message(text: &str, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .p_2() + .items_center() + .justify_center() + .child(Label::new(text.to_owned()).size(LabelSize::Small).color(Color::Muted)) + .bg(cx.theme().colors().editor_background) +} + fn plugin_paths() -> Paths { let root = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 6ac9d02d..138447af 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -12,7 +12,8 @@ use std::rc::Rc; use crate::workspace::{ItemId, PaneId, WorkspaceTabId}; use gpui::EntityId; -use ui::{Tab, prelude::*}; +use ui::{CommonAnimationExt, Tab, prelude::*}; +use zeddy_herdr::control::SessionStatus; /// One row in the sidebar, or one tab in the strip. #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,9 +25,11 @@ pub struct Entry { pub pane: PaneId, pub index: usize, pub title: String, - /// The agent herdr believes is running, when it knows one. In sidebar mode - /// this is a second line; in tabs mode there is no room and it is dropped. - pub agent: Option, + /// Herdr's agent state. Plugins and grouped outer tabs have no aggregate + /// session state of their own. + pub status: Option, + /// A non-agent process currently owns the foreground process group. + pub process_running: bool, /// A session whose reader has stopped is still listed — closing it is the /// user's decision, not something that happens to them. pub ended: bool, @@ -129,15 +132,55 @@ impl Render for DraggedItemPreview { } } -/// The dot that carries a session's state, in the one place both chromes agree -/// on what it means. -pub fn status_dot(entry: &Entry, cx: &App) -> impl IntoElement { - let color = if entry.ended { - cx.theme().status().error - } else if entry.agent.is_some() { - cx.theme().status().success - } else { - cx.theme().colors().text_muted - }; - div().size(px(6.)).rounded_full().bg(color).flex_none() +/// The fixed status mark used by sidebar rows, outer tabs, and pane-local tabs. +/// +/// Herdr owns agent detection and state. Chartr only maps those states to the +/// same visual language the earlier clients used, using Zed's own icons and +/// animation primitive. A plain foreground process gets a slower neutral +/// spinner so it cannot be mistaken for an agent actively working. +pub fn status_indicator( + status: Option, + process_running: bool, + ended: bool, + space: &str, + key: ItemId, + cx: &App, +) -> AnyElement { + let slot = || div().flex_none().size(px(12.)).flex().items_center().justify_center(); + let icon = |name, color| Icon::new(name).size(IconSize::XSmall).color(color); + + if ended { + return slot().child(icon(IconName::XCircle, Color::Error)).into_any_element(); + } + + match status { + Some(SessionStatus::Working) => { + slot() + .child(icon(IconName::LoadCircle, Color::Accent).with_keyed_rotate_animation( + format!("working-status-{space}-{}", key.get()), + 2, + )) + .into_any_element() + } + Some(SessionStatus::Blocked) => { + slot().child(icon(IconName::DebugPause, Color::Warning)).into_any_element() + } + Some(SessionStatus::Done) => { + slot().child(icon(IconName::Check, Color::Success)).into_any_element() + } + Some(SessionStatus::Idle | SessionStatus::Unknown) if process_running => { + slot() + .child(icon(IconName::LoadCircle, Color::Muted).with_keyed_rotate_animation( + format!("process-status-{space}-{}", key.get()), + 5, + )) + .into_any_element() + } + Some(SessionStatus::Idle | SessionStatus::Unknown) => slot() + .child( + div().size(px(5.)).rounded_full().bg(cx.theme().colors().text_muted.opacity(0.28)), + ) + .into_any_element(), + None => slot().into_any_element(), + } } diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 11934bef..8a20b3b1 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -10,7 +10,8 @@ use ui::{Tooltip, prelude::*}; use super::Emit; use super::{ - Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, status_dot, + Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, + status_indicator, }; /// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a @@ -215,17 +216,19 @@ fn row( .when(!grouped, |row| { row.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) }) - .child(status_dot(entry, cx)) + .child(status_indicator( + entry.status, + entry.process_running, + entry.ended, + &entry.space_key, + entry.key, + cx, + )) .child( v_flex() .flex_1() .overflow_hidden() - .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()) - .when_some(entry.agent.clone(), |column, agent| { - column.child( - Label::new(agent).size(LabelSize::XSmall).color(Color::Muted).truncate(), - ) - }), + .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()), ) .when(grouped, |row| { row.child( diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 12fbe55a..ff115c8f 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -6,11 +6,11 @@ //! identity. use gpui::Role; -use ui::{Tab, TabPosition, Tooltip, prelude::*}; +use ui::{ButtonSize, IconButtonShape, Tab, TabPosition, Tooltip, prelude::*}; use super::Emit; -use super::{Action, DraggedItem, Entry, dragged_item_preview, status_dot}; +use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; pub fn render( entries: &[Entry], @@ -92,6 +92,8 @@ fn tab( }; let close_slot: Option = entry.closable.then(|| { IconButton::new(("close", index), IconName::Close) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) .icon_size(IconSize::XSmall) .tooltip(Tooltip::text("Close")) .on_click(move |_, window, cx| { @@ -148,7 +150,14 @@ fn tab( cx, ); }) - .start_slot(status_dot(entry, cx)) + .start_slot(status_indicator( + entry.status, + entry.process_running, + entry.ended, + &entry.space_key, + entry.key, + cx, + )) .end_slot::(close_slot) .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()) } diff --git a/crates/zeddy/src/item.rs b/crates/zeddy/src/item.rs index 719d393e..458163ec 100644 --- a/crates/zeddy/src/item.rs +++ b/crates/zeddy/src/item.rs @@ -23,13 +23,17 @@ impl Item { } } - pub fn agent(&self) -> Option { + pub fn status(&self) -> Option { match self { - Self::Session(item) => item.session.info.agent.clone(), + Self::Session(item) => Some(item.session.info.status), Self::Plugin(_) => None, } } + pub fn process_running(&self) -> bool { + matches!(self, Self::Session(item) if item.session.info.process_running()) + } + pub fn ended(&self) -> bool { matches!(self, Self::Session(item) if item.session.ended().is_some()) } diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 98b967bb..8effeb9f 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -125,7 +125,7 @@ impl Session { /// The live title inferred by the control plane: detected agent, foreground /// process, then Herdr's persistent tab label. pub fn title(&self) -> String { - self.info.title.clone() + self.info.title().to_owned() } /// Send typed bytes to the session. diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index c372ea22..2851ab34 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -490,7 +490,7 @@ impl Space { .tabs() .iter() .filter_map(|tab| { - let id = tab.active_item()?; + let id = tab.representative_item()?; let pane = tab.layout.pane_for_item(id)?; let index = tab.layout.pane(pane)?.items().iter().position(|candidate| *candidate == id)?; @@ -504,8 +504,9 @@ impl Space { pane, index, title: if grouped { "Grouped Tabs".to_owned() } else { item.title() }, - agent: item.agent(), - ended: item.ended(), + status: (!grouped).then(|| item.status()).flatten(), + process_running: !grouped && item.process_running(), + ended: !grouped && item.ended(), selected: self.layout.active_tab_id() == Some(tab.id), closable: true, grouped, diff --git a/crates/zeddy/src/web_plugin.rs b/crates/zeddy/src/web_plugin.rs index dee091e2..bbb9947b 100644 --- a/crates/zeddy/src/web_plugin.rs +++ b/crates/zeddy/src/web_plugin.rs @@ -365,7 +365,7 @@ fn handle_request( serde_json::json!({ "id": session.info.id.0, "workspace": session.info.workspace.0, - "title": session.info.title, + "title": session.info.title(), "agent": session.info.agent, "cwd": session.info.cwd, }) diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index 885d1755..0f7210eb 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -803,6 +803,18 @@ impl WorkspaceTab { pub fn active_item(&self) -> Option { self.layout.pane(self.layout.active_pane()).and_then(Pane::active) } + + /// The item outer chrome can use to identify this tab even when its active + /// pane is an empty Zed-style drop target. + pub fn representative_item(&self) -> Option { + self.active_item().or_else(|| { + self.layout + .center + .panes() + .into_iter() + .find_map(|pane| self.layout.pane(pane)?.items().first().copied()) + }) + } } /// The outer tab collection for one Chartr space. @@ -1357,6 +1369,36 @@ mod tests { workspace.validate().unwrap(); } + #[test] + fn an_empty_active_pane_does_not_hide_its_outer_group() { + let mut tabs = WorkspaceTabs::new(); + let item = tabs.alloc_item(); + let tab = tabs.push_standalone(item).unwrap(); + let occupied = tabs.location(item).unwrap().1; + let empty = + tabs.workspace_mut(tab).unwrap().split_pane(occupied, SplitDirection::Right).unwrap(); + + let grouped = tabs.tab(tab).unwrap(); + assert_eq!(grouped.layout.active_pane(), empty); + assert_eq!(grouped.active_item(), None); + assert_eq!(grouped.representative_item(), Some(item)); + tabs.validate().unwrap(); + } + + #[test] + fn closing_an_empty_active_pane_focuses_its_neighbor() { + let mut workspace = Workspace::new(); + let occupied = workspace.active_pane(); + let item = workspace.alloc_item(); + workspace.add_item(item, Some(occupied), None).unwrap(); + let empty = workspace.split_pane(occupied, SplitDirection::Right).unwrap(); + + assert!(workspace.remove_empty_pane(empty).unwrap()); + assert_eq!(workspace.active_pane(), occupied); + assert_eq!(workspace.center.panes(), vec![occupied]); + workspace.validate().unwrap(); + } + #[test] fn same_axis_splits_extend_the_axis_and_cross_axis_splits_nest() { let mut workspace = Workspace::new(); From af4ef7a5e645ab283be5a314bd8249c74ffcb763 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 03:38:39 +0800 Subject: [PATCH 007/110] Make terminal resizing follow pane topology changes --- crates/zeddy/src/session.rs | 59 +++++++++++++++++++++++++++++------- crates/zeddy/src/terminal.rs | 34 +++++++++++++++++---- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 8effeb9f..b6943093 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -22,7 +22,7 @@ use futures::channel::mpsc; use zeddy_herdr::{ Geometry, PaneId, control::{self, Client}, - stream::Input, + stream::{Frame, Input}, }; use zeddy_vt::{Screen, Size, Terminal}; @@ -74,16 +74,8 @@ impl Session { let outcome = loop { match frames.next_frame() { Ok(Some(frame)) => { - // A full repaint after a resize is measured - // against a grid of its own size, so the - // emulator follows the frame rather than the - // window: applying an 80-column repaint to a - // 120-column grid would wrap it wrongly. let mut terminal = terminal.lock().expect("terminal mutex"); - if frame.full { - terminal.resize(size_of(frame.geometry)); - } - terminal.feed(&frame.bytes); + apply_frame(&mut terminal, &frame); } Ok(None) => break Ended::Closed, Err(err) => break Ended::Failed(err.to_string()), @@ -136,12 +128,15 @@ impl Session { /// Tell the session how many cells it now has. /// /// A no-op at the same size, because a resize costs a full repaint and the - /// window recomputes its cell count on every layout pass. + /// window recomputes its cell count on every layout pass. The local grid is + /// resized before the command crosses the process boundary, so a divider + /// drag reflows on the next paint instead of waiting for herdr's repaint. pub fn resize(&mut self, size: Size) -> zeddy_herdr::Result<()> { if size == self.size { return Ok(()); } self.size = size; + self.terminal.lock().expect("terminal mutex").resize(size); self.input.lock().expect("session input mutex").resize(geometry(size)) } @@ -186,6 +181,23 @@ fn size_of(geometry: Geometry) -> Size { Size::new(geometry.cols, geometry.rows) } +/// Paint a frame only when it was produced for the grid the window currently +/// owns. +/// +/// Resizing the local emulator immediately leaves a small interval in which +/// herdr can still deliver frames queued for the previous geometry. Feeding +/// one of those into the new grid would wrap and position its contents against +/// the wrong width. The stream still consumes those frames to preserve its +/// sequence contract; herdr's full repaint for the current geometry resumes +/// painting. +fn apply_frame(terminal: &mut Terminal, frame: &Frame) -> bool { + if terminal.size() != size_of(frame.geometry) { + return false; + } + terminal.feed(&frame.bytes); + true +} + #[cfg(test)] mod tests { use super::*; @@ -194,4 +206,29 @@ mod tests { fn the_two_grid_types_round_trip() { assert_eq!(size_of(geometry(Size::new(120, 40))), Size::new(120, 40)); } + + #[test] + fn a_frame_for_the_current_grid_is_applied() { + let mut terminal = Terminal::new(Size::new(120, 40)); + let frame = Frame { + bytes: b"current".to_vec(), + full: true, + seq: 1, + geometry: Geometry::new(120, 40), + }; + + assert!(apply_frame(&mut terminal, &frame)); + assert_eq!(terminal.screen().to_text().lines().next(), Some("current")); + } + + #[test] + fn a_queued_full_repaint_for_the_previous_grid_is_ignored() { + let mut terminal = Terminal::new(Size::new(120, 40)); + let frame = + Frame { bytes: b"stale".to_vec(), full: true, seq: 1, geometry: Geometry::new(80, 24) }; + + assert!(!apply_frame(&mut terminal, &frame)); + assert_eq!(terminal.screen().to_text().lines().next(), Some("")); + assert_eq!(terminal.size(), Size::new(120, 40)); + } } diff --git a/crates/zeddy/src/terminal.rs b/crates/zeddy/src/terminal.rs index 76ce12d2..f608d166 100644 --- a/crates/zeddy/src/terminal.rs +++ b/crates/zeddy/src/terminal.rs @@ -11,8 +11,10 @@ //! How many cells fit is a question only the paint pass can answer — it depends //! on the font metrics and on the bounds the layout gave us. But the *answer* //! belongs to the session, which has to tell herdr about it. So the element -//! writes the measured grid into a shared [`Fit`] and the view reads it, which -//! is why a resize takes effect on the frame after the one that noticed it. +//! writes the measured grid into a shared [`Fit`] and the view reads it. A +//! changed fit explicitly schedules that follow-up frame: pane-tree edits such +//! as splits and tab moves are one-shot events, so there may be no mouse event +//! or terminal repaint to schedule it for us. use std::{cell::Cell as StdCell, rc::Rc}; @@ -39,8 +41,8 @@ impl Fit { self.0.get() } - fn set(&self, size: Size) { - self.0.set(Some(size)); + fn set(&self, size: Size) -> bool { + self.0.replace(Some(size)) != Some(size) } } @@ -130,7 +132,7 @@ impl Element for TerminalElement { bounds: Bounds, _: &mut (), window: &mut Window, - _: &mut App, + cx: &mut App, ) -> Metrics { // The font is monospace, so one glyph's advance is every glyph's. let em = window @@ -152,10 +154,16 @@ impl Element for TerminalElement { .max(px(1.)); let cell = size(em, self.appearance.line_height); - self.fit.set(Size::new( + let fit_changed = self.fit.set(Size::new( (bounds.size.width / cell.width).floor() as u16, (bounds.size.height / cell.height).floor() as u16, )); + if fit_changed { + // `Window::refresh` is intentionally ignored while GPUI is in a + // draw pass. Defer it until the pass completes so the next render + // can apply this fit before taking the terminal screen snapshot. + window.defer(cx, |window, _| window.refresh()); + } Metrics { cell } } @@ -274,3 +282,17 @@ impl Look { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_reports_only_real_grid_changes() { + let fit = Fit::default(); + assert!(fit.set(Size::new(80, 24))); + assert!(!fit.set(Size::new(80, 24))); + assert!(fit.set(Size::new(120, 40))); + assert_eq!(fit.get(), Some(Size::new(120, 40))); + } +} From 9da0e43aee3580d2fbb291d9c666d59692d02efd Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 14:51:51 +0800 Subject: [PATCH 008/110] feat: unify workspace chrome and settings --- Cargo.lock | 13 +- Cargo.toml | 1 + README.md | 17 +- crates/zeddy/Cargo.toml | 1 + crates/zeddy/src/app.rs | 1231 ++++----------- crates/zeddy/src/chrome.rs | 7 +- crates/zeddy/src/chrome/sidebar.rs | 149 +- crates/zeddy/src/chrome/tabs.rs | 12 +- crates/zeddy/src/keymap.rs | 2 + crates/zeddy/src/main.rs | 16 +- crates/zeddy/src/mode.rs | 14 - crates/zeddy/src/settings.rs | 488 +++++- crates/zeddy/src/settings_window.rs | 1317 +++++++++++++++++ crates/zeddy/src/space.rs | 9 +- crates/zeddy/src/text_input.rs | 1053 +++++++++++++ docs/acceptance.md | 13 +- .../0005-spaces-follow-zed-multi-workspace.md | 2 +- 17 files changed, 3250 insertions(+), 1095 deletions(-) create mode 100644 crates/zeddy/src/settings_window.rs create mode 100644 crates/zeddy/src/text_input.rs diff --git a/Cargo.lock b/Cargo.lock index 0a0c0a49..578d4645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1844,7 +1844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4183,7 +4183,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.119", @@ -5614,7 +5614,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5627,7 +5627,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6569,7 +6569,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7775,7 +7775,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8675,6 +8675,7 @@ dependencies = [ "theme", "toml 0.9.12+spec-1.1.0", "ui", + "unicode-segmentation", "ureq", "url", "wry", diff --git a/Cargo.toml b/Cargo.toml index 16ceb21c..0d247a6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ tempfile = "3" url = "2" wry = "0.56.1" ureq = "3" +unicode-segmentation = "1" # The dev profile is the build whose window you actually drag. GPUI and the VT # core are both unusably slow at opt-level 0, and neither is code we are diff --git a/README.md b/README.md index 49b3671b..cdcdc613 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ backend instead of exposing web panes that fail only on Wayland. ## Spaces, panes, and items One window owns ordered spaces and one active space, following Zed's -`MultiWorkspace` responsibility. The permanent **Ad-hoc sessions** space is +`MultiWorkspace` responsibility. The permanent **Free sessions** space is folderless and starts sessions in the home directory (or its configured replacement). Folder spaces are canonical-path identities with independent outer tab collections and recursive pane groups. @@ -59,11 +59,16 @@ titles. Collapsed pane groups use the neutral title **Grouped Tabs**. ## Settings and persistence -Settings is presented inside the main window, retaining the spaces sidebar. -The implemented pages are General, Appearance, Terminal, Hotkeys, and Plugins. -Changes are written atomically; hotkeys are semantic GPUI actions with conflict -detection. Chartr Dark is the fixed default, with Chartr Light and system theme -pairs available. IBM Plex Sans and the bundled IBM Plex Mono are configurable +Settings uses one application-wide native window, following Zed: every gear, +the command palette, and `Cmd/Ctrl+,` opens it or focuses the existing instance. +It closes with the native window controls or `Cmd/Ctrl+W`, and closes when the +last workspace window closes. The implemented pages are General, Appearance, +Terminal, Hotkeys, and Plugins. Changes update every workspace live and are +written atomically; hotkeys are semantic GPUI actions with conflict detection. +Chartr Dark is the fixed default. Appearance exposes the same Ayu, Catppuccin, +Gruvbox, One, and VS Code catalog as Chartr-rs, plus Chartr Dark and Chartr +Light. Fixed mode chooses one theme; Match System keeps independent light and +dark selections. IBM Plex Sans and the bundled IBM Plex Mono are configurable defaults. User-editable data remains text: diff --git a/crates/zeddy/Cargo.toml b/crates/zeddy/Cargo.toml index 46d878e3..5f096b74 100644 --- a/crates/zeddy/Cargo.toml +++ b/crates/zeddy/Cargo.toml @@ -29,6 +29,7 @@ tempfile.workspace = true rusqlite.workspace = true url.workspace = true ureq.workspace = true +unicode-segmentation.workspace = true [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] wry.workspace = true diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 0c290963..d2350b81 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -18,8 +18,8 @@ use gpui::{ Role, }; use ui::{ - Banner, ButtonSize, ContextMenu, DropdownMenu, DropdownStyle, IconButtonShape, IconPosition, - ListItem, ListItemSpacing, PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, + Banner, ButtonLike, ButtonSize, ContextMenu, IconButtonShape, IconPosition, ListItem, + ListItemSpacing, PopoverMenu, Severity, Tab, TabBar, TabPosition, TintColor, Tooltip, prelude::*, }; use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; @@ -31,20 +31,17 @@ use crate::{ chrome::{self, Action, DraggedItem, Entry, SpaceEntries, dragged_item_preview}, fonts::Fonts, item::PluginItem, - keymap::{KeymapAction, KeymapStore}, keys, mode::Mode, palette, persistence::{ SidebarScope, Snapshot, SpaceKind as PersistedSpaceKind, StateStore, WindowState, }, - settings::{ - AppearanceContent, CHARTR_DARK, CHARTR_LIGHT, GeneralContent, PluginSettingsContent, - ResolvedSettings, SettingsPage, SettingsStore, TerminalContent, ThemeMode, - }, + settings::{PluginSettingsContent, ResolvedSettings, SettingsStore}, space::{Kind as SpaceKind, Space, name_for}, spaces::{self, Registry}, terminal::{Appearance, TerminalElement}, + text_input::{InputEvent, TextInput}, workspace::{ Axis as PaneAxisDirection, Member, PaneId as LayoutPaneId, SplitDirection, Workspace, WorkspaceTabId, @@ -63,6 +60,19 @@ enum Backend { Failed(String), } +#[derive(Clone)] +pub(crate) struct SettingsPluginDescriptor { + pub manifest: zeddy_plugin::manifest::Manifest, + pub enabled: bool, + pub has_settings: bool, +} + +#[derive(Clone)] +pub(crate) struct SettingsPluginRejection { + pub dir: PathBuf, + pub why: String, +} + #[derive(Clone)] struct DraggedPaneDivider { axis_path: Vec, @@ -134,17 +144,13 @@ pub struct Zeddy { mode: Mode, catalog: Catalog, plugins_restored: bool, - plugin_settings: Option<(String, AnyView)>, settings: SettingsStore, - keymap: KeymapStore, - settings_open: bool, - settings_page: SettingsPage, - recording_keymap: Option, - keymap_restart_required: bool, command_palette_open: bool, + command_palette_input: Entity, command_palette_query: String, command_palette_selected: usize, rename_space: Option, + rename_input: Entity, rename_query: String, sidebar_scope: SidebarScope, sidebar_width: f32, @@ -156,12 +162,26 @@ pub struct Zeddy { } impl Zeddy { - pub fn new( - cwd: PathBuf, - settings: SettingsStore, - keymap: KeymapStore, - cx: &mut Context, - ) -> Self { + pub fn new(cwd: PathBuf, cx: &mut Context) -> Self { + let settings = cx.global::().clone(); + cx.observe_global::(|this, cx| { + this.settings = cx.global::().clone(); + cx.notify(); + }) + .detach(); + let command_palette_input = cx.new(|cx| TextInput::new("Type a command…", cx)); + let rename_input = cx.new(|cx| TextInput::new("Type a space name…", cx)); + cx.subscribe(&command_palette_input, |this, input, _: &InputEvent, cx| { + this.command_palette_query = input.read(cx).text().to_owned(); + this.command_palette_selected = 0; + cx.notify(); + }) + .detach(); + cx.subscribe(&rename_input, |this, input, _: &InputEvent, cx| { + this.rename_query = input.read(cx).text().to_owned(); + cx.notify(); + }) + .detach(); let (state, saved, state_problem) = match crate::persistence::state_file().and_then(StateStore::open) { Ok(store) => match store.load() { @@ -188,17 +208,13 @@ impl Zeddy { mode: Mode::default(), catalog: Catalog::default(), plugins_restored: true, - plugin_settings: None, settings, - keymap, - settings_open: false, - settings_page: SettingsPage::default(), - recording_keymap: None, - keymap_restart_required: false, command_palette_open: false, + command_palette_input, command_palette_query: String::new(), command_palette_selected: 0, rename_space: None, + rename_input, rename_query: String::new(), sidebar_scope: saved.window.sidebar_scope, sidebar_width: saved.window.sidebar_width, @@ -220,13 +236,13 @@ impl Zeddy { .or_else(std::env::home_dir) .filter(|path| path.is_absolute()) .unwrap_or_else(|| cwd.clone()); - descriptors.push(("Ad-hoc sessions".to_owned(), home.clone(), SpaceKind::AdHoc)); + descriptors.push(("Free sessions".to_owned(), home.clone(), SpaceKind::AdHoc)); if let Some(registry) = registry.as_ref() { descriptors.extend( registry .spaces() .iter() - // The synthetic ad-hoc space already owns the home + // The synthetic Free sessions space already owns the home // workspace. herdr has one workspace per directory, so a // second row for the same path could not own independent // sessions and would be a false distinction. @@ -305,17 +321,13 @@ impl Zeddy { mode: saved.window.chrome, catalog, plugins_restored: false, - plugin_settings: None, settings, - keymap, - settings_open: false, - settings_page: SettingsPage::default(), - recording_keymap: None, - keymap_restart_required: false, command_palette_open: false, + command_palette_input, command_palette_query: String::new(), command_palette_selected: 0, rename_space: None, + rename_input, rename_query: String::new(), sidebar_scope: saved.window.sidebar_scope, sidebar_width: saved.window.sidebar_width, @@ -778,13 +790,7 @@ impl Zeddy { fn act(&mut self, action: Action, window: &mut Window, cx: &mut Context) { match action { - Action::ToggleMode => self.mode = self.mode.toggled(), - Action::ToggleSidebarScope => { - self.sidebar_scope = match self.sidebar_scope { - SidebarScope::AllSpaces => SidebarScope::ActiveSpace, - SidebarScope::ActiveSpace => SidebarScope::AllSpaces, - } - } + Action::OpenSettings => self.open_settings(window, cx), Action::New => { if matches!(self.backend, Backend::Ready) && let Some(space) = self.active.clone() @@ -824,7 +830,10 @@ impl Zeddy { { self.rename_space = Some(space); self.rename_query = target.read(cx).name().to_owned(); - window.focus(&self.focus, cx); + self.rename_input.update(cx, |input, cx| { + input.set_text(self.rename_query.clone(), true, cx) + }); + window.focus(&self.rename_input.focus_handle(cx), cx); } } Action::LocateSpace { space } => self.locate_space(space, cx), @@ -854,12 +863,6 @@ impl Zeddy { cx.notify(); return; } - if self.settings_open { - self.settings_open = false; - self.plugin_settings = None; - cx.notify(); - return; - } let Some(space) = self.active.clone() else { return; }; @@ -1026,12 +1029,16 @@ impl Zeddy { } } - fn commit_space_rename(&mut self, cx: &mut Context) { + fn commit_space_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(id) = self.rename_space.take() else { return; }; - let name = self.rename_query.trim().to_owned(); + // Read from the input directly so Enter always commits the latest IME + // transaction, even before the subscription's mirrored value flushes. + let name = self.rename_input.read(cx).text().trim().to_owned(); self.rename_query.clear(); + self.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&self.focus, cx); let Some(space) = self.spaces.iter().find(|space| space.entity_id() == id).cloned() else { return; }; @@ -1094,84 +1101,103 @@ impl Zeddy { .detach(); } - fn open_settings(&mut self, cx: &mut Context) { + fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { self.command_palette_open = false; - self.settings_open = true; - self.settings_page = SettingsPage::default(); - self.plugin_settings = None; + self.command_palette_query.clear(); + self.command_palette_input.update(cx, |input, cx| input.clear(cx)); + let Some(original_window) = window.window_handle().downcast::() else { + return; + }; + crate::settings_window::open(original_window, cx.weak_entity(), cx); cx.notify(); } - fn cycle_settings_page(&mut self, backwards: bool, cx: &mut Context) { - let current = - SettingsPage::ALL.iter().position(|page| *page == self.settings_page).unwrap_or(0); - let next = if backwards { - current.checked_sub(1).unwrap_or(SettingsPage::ALL.len() - 1) - } else { - (current + 1) % SettingsPage::ALL.len() - }; - self.settings_page = SettingsPage::ALL[next]; - self.plugin_settings = None; + fn close_plugin_instances(&mut self, plugin: &str, cx: &mut Context) { + for space in &self.spaces { + let ids = space.read(cx).plugin_item_ids(plugin); + space.update(cx, |space, _| space.finish_bulk_close(&ids)); + } + } + + pub(crate) fn settings_backend_label(&self) -> String { + match &self.backend { + Backend::Ready => "Connected".to_owned(), + Backend::Starting => "Starting".to_owned(), + Backend::Recovering(detail) | Backend::Failed(detail) => detail.clone(), + } + } + + pub(crate) fn settings_sidebar_scope(&self) -> SidebarScope { + self.sidebar_scope + } + + pub(crate) fn settings_mode(&self) -> Mode { + self.mode + } + + pub(crate) fn settings_set_mode(&mut self, mode: Mode, cx: &mut Context) { + self.mode = mode; cx.notify(); } - fn set_theme_preference( + pub(crate) fn settings_set_sidebar_scope( &mut self, - mode: ThemeMode, - fixed_theme: Option<&str>, + scope: SidebarScope, cx: &mut Context, ) { - let result = self.settings.update(|content| { - let appearance = content.appearance.get_or_insert_with(AppearanceContent::default); - appearance.theme_mode = Some(mode); - if let Some(theme) = fixed_theme { - appearance.fixed_theme = Some(theme.to_owned()); - } - }); - match result { - Ok(settings) => { - crate::settings::apply_theme(settings, cx); - theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); - self.problem = None; - } - Err(error) => self.problem = Some(error.to_string()), - } + self.sidebar_scope = scope; cx.notify(); } - fn set_terminate_on_exit(&mut self, enabled: bool, cx: &mut Context) { - let result = self.settings.update(|content| { - content - .general - .get_or_insert_with(GeneralContent::default) - .terminate_sessions_on_exit = Some(enabled); - }); - match result { - Ok(_) => self.problem = None, - Err(error) => self.problem = Some(error.to_string()), - } - cx.notify(); + pub(crate) fn settings_retry_backend(&mut self, cx: &mut Context) { + self.retry_backend(false, cx); } - fn close_plugin_instances(&mut self, plugin: &str, cx: &mut Context) { - for space in &self.spaces { - let ids = space.read(cx).plugin_item_ids(plugin); - space.update(cx, |space, _| space.finish_bulk_close(&ids)); - } + pub(crate) fn settings_restart_backend(&mut self, window: &mut Window, cx: &mut Context) { + self.request_backend_restart(window, cx); } - fn set_plugin_enabled(&mut self, plugin: String, enabled: bool, cx: &mut Context) { + pub(crate) fn settings_plugins( + &self, + ) -> (Vec, Vec) { + let mut descriptors: Vec<_> = self + .catalog + .loaded + .values() + .map(|loaded| SettingsPluginDescriptor { + manifest: loaded.manifest.clone(), + enabled: true, + has_settings: loaded.has_settings, + }) + .chain(self.catalog.disabled.values().map(|disabled| SettingsPluginDescriptor { + manifest: disabled.manifest.clone(), + enabled: false, + has_settings: false, + })) + .collect(); + descriptors.sort_by(|left, right| left.manifest.name.cmp(&right.manifest.name)); + let rejected = self + .catalog + .rejected + .iter() + .map(|rejected| SettingsPluginRejection { + dir: rejected.dir.clone(), + why: rejected.why.clone(), + }) + .collect(); + (descriptors, rejected) + } + + pub(crate) fn settings_set_plugin_enabled( + &mut self, + plugin: String, + enabled: bool, + cx: &mut Context, + ) -> Result<(), String> { if enabled { - match self.catalog.enable(&plugin_paths(), &plugin, cx) { - Ok(()) => {} - Err(error) => { - self.problem = Some(error.to_string()); - cx.notify(); - return; - } - } + self.catalog.enable(&plugin_paths(), &plugin, cx).map_err(|error| error.to_string())?; } - let result = self.settings.update(|content| { + let result = crate::settings::update_global(cx, |content| { content .plugins .entry(plugin.clone()) @@ -1182,65 +1208,61 @@ impl Zeddy { Ok(_) => { if !enabled { self.close_plugin_instances(&plugin, cx); - if self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { - self.plugin_settings = None; - } self.catalog.disable(&plugin); } self.problem = None; + cx.notify(); + Ok(()) } Err(error) => { if enabled { self.catalog.disable(&plugin); } - self.problem = Some(error.to_string()); + Err(error.to_string()) } } - cx.notify(); } - fn set_plugin_unsafe(&mut self, plugin: String, enabled: bool, cx: &mut Context) { - let result = self.settings.update(|content| { + pub(crate) fn settings_set_plugin_unsafe( + &mut self, + plugin: String, + enabled: bool, + cx: &mut Context, + ) -> Result<(), String> { + crate::settings::update_global(cx, |content| { content .plugins .entry(plugin.clone()) .or_insert_with(PluginSettingsContent::default) .unsafe_filesystem = Some(enabled); - }); - match result { - Ok(_) => { - // Brokers are instance-owned. Destroying the view is the - // revocation boundary; reopening constructs one with the new grant. - self.close_plugin_instances(&plugin, cx); - if self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { - self.plugin_settings = None; - } - self.problem = None; - } - Err(error) => self.problem = Some(error.to_string()), - } + }) + .map_err(|error| error.to_string())?; + // Brokers are instance-owned. Destroying every instance is the + // revocation boundary; reopening constructs one with the new grant. + self.close_plugin_instances(&plugin, cx); + self.problem = None; cx.notify(); + Ok(()) } - fn open_plugin_settings( + pub(crate) fn settings_plugin_view( &mut self, - plugin: String, + plugin: &str, window: &mut Window, cx: &mut Context, - ) { - let Some(loaded) = self.catalog.get(&plugin) else { - return; - }; + ) -> Option { + let loaded = self.catalog.get(plugin)?; let permissions = loaded.permissions().clone(); - let unsafe_filesystem = self.settings.resolved().plugin(&plugin).unsafe_filesystem; - let source = self.catalog.get_mut(&plugin).and_then(|loaded| loaded.settings(window, cx)); - let view = match source { - Some(SettingsSource::Native(view)) => view, - Some(SettingsSource::Web(entry)) => crate::web_plugin::view( + let unsafe_filesystem = + cx.global::().resolved().plugin(plugin).unsafe_filesystem; + let source = self.catalog.get_mut(plugin)?.settings(window, cx)?; + Some(match source { + SettingsSource::Native(view) => view, + SettingsSource::Web(entry) => crate::web_plugin::view( entry, FileBroker::new( None, - plugin_paths().data.join(&plugin), + plugin_paths().data.join(plugin), permissions.project_files, unsafe_filesystem, ), @@ -1250,110 +1272,22 @@ impl Zeddy { window, cx, ), - None => return, - }; - self.plugin_settings = Some((plugin, view)); - cx.notify(); - } - - fn set_ui_font(&mut self, family: String, cx: &mut Context) { - let result = self.settings.update(|content| { - content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_family = - Some(family); - }); - match result { - Ok(settings) => { - theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); - self.problem = None; - } - Err(error) => self.problem = Some(error.to_string()), - } - cx.notify(); - } - - fn adjust_ui_font_size(&mut self, delta: f32, cx: &mut Context) { - let current = self.settings.resolved().ui_font_size; - let result = self.settings.update(|content| { - content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_size = - Some((current + delta).clamp(8., 32.)); - }); - match result { - Ok(settings) => { - theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); - self.problem = None; - } - Err(error) => self.problem = Some(error.to_string()), - } - cx.notify(); - } - - fn set_terminal_font(&mut self, family: String, cx: &mut Context) { - let result = self.settings.update(|content| { - content.terminal.get_or_insert_with(TerminalContent::default).font_family = - Some(family); - }); - match result { - Ok(_) => self.problem = None, - Err(error) => self.problem = Some(error.to_string()), - } - cx.notify(); + }) } - fn adjust_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { - let current = self.settings.resolved().terminal_font_size; - let result = self.settings.update(|content| { - content.terminal.get_or_insert_with(TerminalContent::default).font_size = - Some((current + delta).clamp(8., 72.)); - }); - match result { - Ok(_) => self.problem = None, - Err(error) => self.problem = Some(error.to_string()), + pub(crate) fn settings_set_free_sessions_directory( + &mut self, + path: PathBuf, + cx: &mut Context, + ) { + if let Some(space) = + self.spaces.iter().find(|space| space.read(cx).kind() == SpaceKind::AdHoc) + { + space.update(cx, |space, _| space.set_path(path)); } cx.notify(); } - fn pick_ad_hoc_directory(&mut self, cx: &mut Context) { - let chosen = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Use for Ad-hoc sessions".into()), - }); - cx.spawn(async move |this, cx| { - let outcome = chosen.await; - let _ = this.update(cx, |this, cx| match outcome { - Ok(Ok(Some(paths))) if !paths.is_empty() => { - let path = paths[0].clone(); - match this.settings.update(|content| { - content - .terminal - .get_or_insert_with(TerminalContent::default) - .ad_hoc_directory = Some(path.clone()); - }) { - Ok(_) => { - if let Some(space) = this - .spaces - .iter() - .find(|space| space.read(cx).kind() == SpaceKind::AdHoc) - { - space.update(cx, |space, _| space.set_path(path)); - } - this.problem = None; - } - Err(error) => this.problem = Some(error.to_string()), - } - cx.notify(); - } - Ok(Ok(_)) | Err(_) => {} - Ok(Err(error)) => { - this.problem = Some(error.to_string()); - cx.notify(); - } - }); - }) - .detach(); - } - fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(space) = self.active.clone() { space.update(cx, |space, _| space.split_and_move(direction)); @@ -1418,8 +1352,13 @@ impl Zeddy { fn toggle_command_palette(&mut self, window: &mut Window, cx: &mut Context) { self.command_palette_open = !self.command_palette_open; self.command_palette_query.clear(); + self.command_palette_input.update(cx, |input, cx| input.clear(cx)); self.command_palette_selected = 0; - window.focus(&self.focus, cx); + if self.command_palette_open { + window.focus(&self.command_palette_input.focus_handle(cx), cx); + } else { + window.focus(&self.focus, cx); + } cx.notify(); } @@ -1439,6 +1378,7 @@ impl Zeddy { ) { self.command_palette_open = false; self.command_palette_query.clear(); + self.command_palette_input.update(cx, |input, cx| input.clear(cx)); let action: Box = match command { PaletteCommand::NewTerminal => Box::new(actions::workspace::NewTerminal), PaletteCommand::CloseItem => Box::new(actions::pane::CloseActiveItem), @@ -1466,70 +1406,36 @@ impl Zeddy { fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { if self.rename_space.is_some() { - cx.stop_propagation(); match event.keystroke.key.as_str() { "escape" => { + cx.stop_propagation(); self.rename_space = None; self.rename_query.clear(); + self.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&self.focus, cx); } "enter" => { - self.commit_space_rename(cx); + cx.stop_propagation(); + self.commit_space_rename(window, cx); return; } - "backspace" => { - self.rename_query.pop(); - } - _ if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.platform - && !event.keystroke.modifiers.alt => - { - if let Some(text) = event.keystroke.key_char.as_deref() { - self.rename_query.push_str(text); - } - } - _ => {} - } - cx.notify(); - return; - } - if let Some(action) = self.recording_keymap { - cx.stop_propagation(); - if event.keystroke.key == "escape" { - self.recording_keymap = None; - cx.notify(); - return; - } - if matches!( - event.keystroke.key.as_str(), - "shift" | "control" | "alt" | "cmd" | "super" | "fn" - ) { - return; - } - let key = event.keystroke.unparse(); - match self.keymap.set(action, key) { - Ok(()) => { - self.recording_keymap = None; - self.keymap_restart_required = true; - self.problem = None; - } - Err(error) => self.problem = Some(error.to_string()), + _ => return, } cx.notify(); return; } if self.command_palette_open { - cx.stop_propagation(); let key = event.keystroke.key.as_str(); match key { "escape" => { + cx.stop_propagation(); self.command_palette_open = false; self.command_palette_query.clear(); - } - "backspace" => { - self.command_palette_query.pop(); - self.command_palette_selected = 0; + self.command_palette_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&self.focus, cx); } "up" => { + cx.stop_propagation(); let count = self.filtered_palette_commands().len(); if count > 0 { self.command_palette_selected = @@ -1537,12 +1443,14 @@ impl Zeddy { } } "down" => { + cx.stop_propagation(); let count = self.filtered_palette_commands().len(); if count > 0 { self.command_palette_selected = (self.command_palette_selected + 1) % count; } } "enter" => { + cx.stop_propagation(); if let Some((command, _, _)) = self.filtered_palette_commands().get(self.command_palette_selected).copied() { @@ -1550,16 +1458,7 @@ impl Zeddy { return; } } - _ if !event.keystroke.modifiers.control - && !event.keystroke.modifiers.platform - && !event.keystroke.modifiers.alt => - { - if let Some(text) = event.keystroke.key_char.as_deref() { - self.command_palette_query.push_str(text); - self.command_palette_selected = 0; - } - } - _ => {} + _ => return, } cx.notify(); return; @@ -1574,13 +1473,6 @@ impl Zeddy { cx.notify(); return; } - if self.settings_open { - if event.keystroke.modifiers.control && event.keystroke.key == "tab" { - cx.stop_propagation(); - self.cycle_settings_page(event.keystroke.modifiers.shift, cx); - } - return; - } let Some(bytes) = keys::bytes_for(&event.keystroke) else { return; }; @@ -1608,19 +1500,22 @@ impl Zeddy { let menu = ContextMenu::build(window, cx, move |menu, _, _| { let add = weak.clone(); - let mut menu = menu.entry("New space…", None, move |_, cx| { + let mut menu = menu.entry("New Space…", None, move |_, cx| { let _ = add.update(cx, |this, cx| this.pick_a_folder(cx)); }); - for (space, name, _kind) in - spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::AdHoc) - { + let registered: Vec<_> = + spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::Registered).collect(); + if !registered.is_empty() { + menu = menu.separator().header("Project Spaces"); + } + for (space, name, _) in registered { let target = space.clone(); let select = weak.clone(); menu = menu.toggleable_entry( name.clone(), active_id == Some(space.entity_id()), - IconPosition::Start, + IconPosition::End, None, move |window, cx| { let _ = @@ -1629,18 +1524,16 @@ impl Zeddy { ); } - let registered: Vec<_> = - spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::Registered).collect(); - if !registered.is_empty() { - menu = menu.separator(); - } - for (space, name, _) in registered { + menu = menu.separator(); + for (space, name, _kind) in + spaces.iter().filter(|(_, _, kind)| *kind == SpaceKind::AdHoc) + { let target = space.clone(); let select = weak.clone(); menu = menu.toggleable_entry( name.clone(), active_id == Some(space.entity_id()), - IconPosition::Start, + IconPosition::End, None, move |window, cx| { let _ = @@ -1651,11 +1544,28 @@ impl Zeddy { menu }); - DropdownMenu::new("space-switcher", current, menu) - .style(DropdownStyle::Ghost) - .full_width(true) - .attach(Anchor::BottomLeft) - .aria_label("Current space") + let menu_for_open = menu.clone(); + let menu_for_render = menu.clone(); + PopoverMenu::new("space-switcher") + .trigger( + ButtonLike::new("space-switcher-trigger") + .aria_label("Current space") + .aria_value(current.clone()) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .child(div().min_w_0().max_w(px(148.)).child(Label::new(current).truncate())) + .child( + Icon::new(IconName::ChevronUpDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ), + ) + .anchor(Anchor::TopLeft) + .on_open(Rc::new(move |window, cx| { + menu_for_open.update(cx, |menu, cx| { + menu.select_toggled_or_first(window, cx); + }); + })) + .menu(move |_, _| Some(menu_for_render.clone())) .into_any_element() } @@ -2593,6 +2503,7 @@ impl Zeddy { status, process_running, ended, + false, &space_key, *id, cx, @@ -2695,18 +2606,13 @@ impl Zeddy { ) }) .collect(); - let dismiss = cx.listener(|this, _, _, cx| { + let dismiss = cx.listener(|this, _, window, cx| { this.command_palette_open = false; this.command_palette_query.clear(); + this.command_palette_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&this.focus, cx); cx.notify(); }); - let query = if self.command_palette_query.is_empty() { - "Type a command…".to_owned() - } else { - self.command_palette_query.clone() - }; - let query_color = - if self.command_palette_query.is_empty() { Color::Muted } else { Color::Default }; Some( div() @@ -2746,7 +2652,7 @@ impl Zeddy { .size(IconSize::Small) .color(Color::Muted), ) - .child(Label::new(query).size(LabelSize::Small).color(query_color)), + .child(self.command_palette_input.clone()), ) .child( v_flex() @@ -2769,17 +2675,21 @@ impl Zeddy { fn rename_space_overlay(&mut self, cx: &mut Context) -> Option { self.rename_space?; - let cancel_scrim = cx.listener(|this, _, _, cx| { + let cancel_scrim = cx.listener(|this, _, window, cx| { this.rename_space = None; this.rename_query.clear(); + this.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&this.focus, cx); cx.notify(); }); - let cancel_button = cx.listener(|this, _, _, cx| { + let cancel_button = cx.listener(|this, _, window, cx| { this.rename_space = None; this.rename_query.clear(); + this.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&this.focus, cx); cx.notify(); }); - let save = cx.listener(|this, _, _, cx| this.commit_space_rename(cx)); + let save = cx.listener(|this, _, window, cx| this.commit_space_rename(window, cx)); Some( div() .id("rename-space-scrim") @@ -2815,21 +2725,7 @@ impl Zeddy { .border_1() .border_color(cx.theme().colors().border_focused) .bg(cx.theme().colors().editor_background) - .child( - Label::new(if self.rename_query.is_empty() { - "Type a space name…".to_owned() - } else { - self.rename_query.clone() - }) - .size(LabelSize::Small) - .color( - if self.rename_query.is_empty() { - Color::Muted - } else { - Color::Default - }, - ), - ), + .child(self.rename_input.clone()), ) .child( h_flex() @@ -2845,585 +2741,6 @@ impl Zeddy { .into_any_element(), ) } - - fn settings_workspace(&mut self, cx: &mut Context) -> AnyElement { - let close = cx.listener(|this, _, _, cx| { - this.settings_open = false; - this.plugin_settings = None; - cx.notify(); - }); - let selected = self.settings_page; - let navigation: Vec<_> = SettingsPage::ALL - .into_iter() - .map(|page| { - div() - .id(format!("settings-page-{}", page.slug())) - .role(Role::Tab) - .aria_label(page.title()) - .aria_selected(page == selected) - .mx_1() - .px_2() - .py_1() - .rounded_sm() - .cursor_pointer() - .when(page == selected, |row| { - row.bg(cx.theme().colors().element_selected) - .text_color(cx.theme().colors().text) - }) - .when(page != selected, |row| { - row.text_color(cx.theme().colors().text_muted) - .hover(|row| row.bg(cx.theme().colors().element_hover)) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.settings_page = page; - if page != SettingsPage::Plugins { - this.plugin_settings = None; - } - cx.notify(); - })) - .child(Label::new(page.title()).size(LabelSize::Small)) - }) - .collect(); - let content = self.settings_content(cx); - - v_flex() - .id("settings-workspace") - .size_full() - .min_h_0() - .bg(cx.theme().colors().background) - .child( - h_flex() - .h(Tab::container_height(cx)) - .px_3() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(Label::new("Settings").size(LabelSize::Small)) - .child( - IconButton::new("close-settings", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Close Settings")) - .on_click(close), - ), - ) - .child( - h_flex() - .flex_1() - .min_h_0() - .child( - v_flex() - .w(px(176.)) - .h_full() - .py_2() - .border_r_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().surface_background) - .child(div().px_3().py_1().child( - Label::new("Options").size(LabelSize::XSmall).color(Color::Muted), - )) - .children(navigation), - ) - .child(content), - ) - .into_any_element() - } - - fn settings_content(&mut self, cx: &mut Context) -> AnyElement { - let page = self.settings_page; - let plugin_override = (page == SettingsPage::Plugins) - .then(|| self.plugin_settings.as_ref()) - .flatten() - .map(|(plugin, view)| { - let back = cx.listener(|this, _, _, cx| { - this.plugin_settings = None; - cx.notify(); - }); - v_flex() - .gap_3() - .child(Button::new("plugin-settings-back", "Back to plugins").on_click(back)) - .child(Label::new(plugin.clone()).size(LabelSize::XSmall).color(Color::Muted)) - .child(div().min_h(px(320.)).child(view.clone())) - .into_any_element() - }); - let body = if let Some(plugin_override) = plugin_override { - plugin_override - } else { - match page { - SettingsPage::General => { - let terminate = self.settings.resolved().terminate_sessions_on_exit; - let toggle = cx - .listener(move |this, _, _, cx| this.set_terminate_on_exit(!terminate, cx)); - v_flex() - .gap_4() - .child(Label::new("Chartr").size(LabelSize::Large)) - .child( - Label::new(format!( - "Version {} · configuration namespace chartr-zeddy", - env!("CARGO_PKG_VERSION") - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .justify_between() - .gap_4() - .child( - v_flex() - .child( - Label::new("Terminate sessions on exit") - .size(LabelSize::Small), - ) - .child( - Label::new( - "Normal app exit detaches and leaves sessions running.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - Button::new( - "terminate-sessions-on-exit", - if terminate { "On" } else { "Off" }, - ) - .toggle_state(terminate) - .on_click(toggle), - ), - ) - .into_any_element() - } - SettingsPage::Appearance => { - let selected = self.settings.resolved().fixed_theme.clone(); - let mode = self.settings.resolved().theme_mode; - let dark = cx.listener(|this, _, _, cx| { - this.set_theme_preference(ThemeMode::Fixed, Some(CHARTR_DARK), cx) - }); - let light = cx.listener(|this, _, _, cx| { - this.set_theme_preference(ThemeMode::Fixed, Some(CHARTR_LIGHT), cx) - }); - let system = cx.listener(|this, _, _, cx| { - this.set_theme_preference(ThemeMode::System, None, cx) - }); - let font = cx.weak_entity(); - let smaller = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(-1., cx)); - let larger = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(1., cx)); - v_flex() - .gap_3() - .child(setting_label("Theme")) - .child( - h_flex() - .gap_2() - .child( - Button::new("theme-chartr-dark", CHARTR_DARK) - .toggle_state( - mode == ThemeMode::Fixed && selected == CHARTR_DARK, - ) - .on_click(dark), - ) - .child( - Button::new("theme-chartr-light", CHARTR_LIGHT) - .toggle_state( - mode == ThemeMode::Fixed && selected == CHARTR_LIGHT, - ) - .on_click(light), - ) - .child( - Button::new("theme-system", "System") - .toggle_state(mode == ThemeMode::System) - .on_click(system), - ), - ) - .child(setting_label("Interface font")) - .child( - h_flex() - .gap_1() - .child( - PopoverMenu::new("ui-font-menu") - .trigger( - Button::new( - "ui-font-family", - self.settings.resolved().ui_font_family.clone(), - ) - .end_icon(Icon::new(IconName::ChevronDown)), - ) - .anchor(Anchor::BottomLeft) - .menu(move |window, cx| { - let font = font.clone(); - Some(ContextMenu::build( - window, - cx, - move |menu, _, _| { - ["IBM Plex Sans", ".ZedSans", "System UI"] - .into_iter() - .fold(menu, |menu, family| { - let set = font.clone(); - menu.entry( - family, - None, - move |_, cx| { - let _ = set.update( - cx, - |this, cx| { - this.set_ui_font( - family.to_owned(), - cx, - ) - }, - ); - }, - ) - }) - }, - )) - }), - ) - .child( - IconButton::new("ui-font-smaller", IconName::Dash) - .tooltip(Tooltip::text("Decrease interface font size")) - .on_click(smaller), - ) - .child( - Label::new(format!( - "{} px", - self.settings.resolved().ui_font_size - )) - .size(LabelSize::Small), - ) - .child( - IconButton::new("ui-font-larger", IconName::Plus) - .tooltip(Tooltip::text("Increase interface font size")) - .on_click(larger), - ), - ) - .into_any_element() - } - SettingsPage::Terminal => { - let font = cx.weak_entity(); - let smaller = - cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(-1., cx)); - let larger = - cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(1., cx)); - let choose_directory = - cx.listener(|this, _, _, cx| this.pick_ad_hoc_directory(cx)); - let retry = cx.listener(|this, _, _, cx| this.retry_backend(false, cx)); - let restart = - cx.listener(|this, _, window, cx| this.request_backend_restart(window, cx)); - let backend = match &self.backend { - Backend::Ready => "Connected".to_owned(), - Backend::Starting => "Starting".to_owned(), - Backend::Recovering(detail) | Backend::Failed(detail) => detail.clone(), - }; - v_flex() - .gap_3() - .child(setting_label("Terminal font")) - .child( - h_flex() - .gap_1() - .child( - PopoverMenu::new("terminal-font-menu") - .trigger( - Button::new( - "terminal-font-family", - self.settings - .resolved() - .terminal_font_family - .clone(), - ) - .end_icon(Icon::new(IconName::ChevronDown)), - ) - .anchor(Anchor::BottomLeft) - .menu(move |window, cx| { - let font = font.clone(); - Some(ContextMenu::build( - window, - cx, - move |menu, _, _| { - ["IBM Plex Mono", "Lilex", ".ZedMono"] - .into_iter() - .fold(menu, |menu, family| { - let set = font.clone(); - menu.entry( - family, - None, - move |_, cx| { - let _ = set.update( - cx, - |this, cx| { - this.set_terminal_font( - family.to_owned(), - cx, - ) - }, - ); - }, - ) - }) - }, - )) - }), - ) - .child( - IconButton::new("terminal-font-smaller", IconName::Dash) - .tooltip(Tooltip::text("Decrease terminal font size")) - .on_click(smaller), - ) - .child( - Label::new(format!( - "{} px", - self.settings.resolved().terminal_font_size - )) - .size(LabelSize::Small), - ) - .child( - IconButton::new("terminal-font-larger", IconName::Plus) - .tooltip(Tooltip::text("Increase terminal font size")) - .on_click(larger), - ), - ) - .child(setting_label("Ad-hoc directory")) - .child( - Button::new( - "choose-ad-hoc-directory", - self.settings.resolved().ad_hoc_directory.as_ref().map_or_else( - || "Home directory".to_owned(), - |path| path.display().to_string(), - ), - ) - .on_click(choose_directory), - ) - .child(setting_value("Backend", backend)) - .child( - h_flex() - .gap_1() - .child( - Button::new("settings-retry-backend", "Retry").on_click(retry), - ) - .child( - Button::new("settings-restart-backend", "Restart Backend") - .on_click(restart), - ), - ) - .into_any_element() - } - SettingsPage::Hotkeys => { - let recording = self.recording_keymap; - let rows: Vec<_> = KeymapAction::ALL - .into_iter() - .map(|action| { - let capture = cx.listener(move |this, _, _, cx| { - this.recording_keymap = Some(action); - this.problem = None; - cx.notify(); - }); - h_flex() - .justify_between() - .gap_4() - .child(Label::new(action.title()).size(LabelSize::Small)) - .child( - Button::new( - format!("record-hotkey-{}", action.id()), - if recording == Some(action) { - "Press shortcut…".to_owned() - } else { - self.keymap.key(action).to_owned() - }, - ) - .toggle_state(recording == Some(action)) - .on_click(capture), - ) - }) - .collect(); - v_flex() - .gap_2() - .when_some(self.keymap.problem().map(str::to_owned), |view, problem| { - view.child( - Banner::new() - .severity(Severity::Error) - .child(Label::new(problem).size(LabelSize::Small)), - ) - }) - .when(self.keymap_restart_required, |view| { - view.child(Banner::new().child( - Label::new( - "Shortcut changes are saved. Restart Chartr to rebuild the application keymap.", - ) - .size(LabelSize::Small), - )) - }) - .child( - Label::new( - "Click a shortcut, then press one key chord. Conflicts in the Chartr context are rejected.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .children(rows) - .into_any_element() - } - SettingsPage::Plugins => { - let mut descriptors: Vec<_> = self - .catalog - .loaded - .values() - .map(|loaded| (loaded.manifest.clone(), true, loaded.has_settings)) - .chain( - self.catalog - .disabled - .values() - .map(|disabled| (disabled.manifest.clone(), false, false)), - ) - .collect(); - descriptors.sort_by(|(left, _, _), (right, _, _)| left.name.cmp(&right.name)); - let rows: Vec<_> = descriptors - .into_iter() - .map(|(manifest, enabled, has_settings)| { - let id = manifest.id.clone(); - let control_id = id.clone(); - let configured = self.settings.resolved().plugin(&id); - let toggle = cx.listener(move |this, _, _, cx| { - this.set_plugin_enabled(id.clone(), !enabled, cx) - }); - let trust = match manifest.kind { - zeddy_plugin::manifest::Kind::Native => { - "Native — fully trusted code".to_owned() - } - zeddy_plugin::manifest::Kind::Web => { - let project = match manifest.permissions.project_files { - zeddy_plugin::manifest::ProjectAccess::None => { - "no project files" - } - zeddy_plugin::manifest::ProjectAccess::Read => { - "read project files" - } - zeddy_plugin::manifest::ProjectAccess::ReadWrite => { - "read/write project files" - } - }; - let mut grants = vec![project.to_owned()]; - if !manifest.permissions.network.is_empty() { - grants.push(format!( - "network: {}", - manifest.permissions.network.join(", ") - )); - } - if manifest.permissions.process { - grants.push("process actions".to_owned()); - } - if manifest.permissions.session { - grants.push("bound-session actions".to_owned()); - } - format!("Web — {}", grants.join(" · ")) - } - }; - let unsafe_control = - (manifest.kind == zeddy_plugin::manifest::Kind::Web).then(|| { - let id = manifest.id.clone(); - let change = cx.listener(move |this, _, _, cx| { - this.set_plugin_unsafe( - id.clone(), - !configured.unsafe_filesystem, - cx, - ) - }); - Button::new( - format!("plugin-unsafe-{}", manifest.id), - if configured.unsafe_filesystem { - "Unsafe filesystem granted" - } else { - "Grant unsafe filesystem" - }, - ) - .toggle_state(configured.unsafe_filesystem) - .on_click(change) - }); - let configure = has_settings.then(|| { - let id = manifest.id.clone(); - Button::new(format!("plugin-settings-{}", manifest.id), "Configure") - .on_click(cx.listener(move |this, _, window, cx| { - this.open_plugin_settings(id.clone(), window, cx) - })) - }); - v_flex() - .gap_2() - .p_3() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - h_flex() - .justify_between() - .child( - v_flex() - .child( - Label::new(manifest.name) - .size(LabelSize::Small), - ) - .child( - Label::new(manifest.id) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .child( - Button::new( - format!("plugin-enabled-{control_id}"), - if enabled { "Enabled" } else { "Disabled" }, - ) - .toggle_state(enabled) - .on_click(toggle), - ), - ) - .child( - Label::new(trust).size(LabelSize::XSmall).color(Color::Muted), - ) - .when_some(configure, |row, control| row.child(control)) - .when_some(unsafe_control, |row, control| row.child(control)) - }) - .collect(); - let rejected: Vec<_> = self - .catalog - .rejected - .iter() - .map(|rejected| { - Banner::new().severity(Severity::Error).child( - Label::new(format!("{}: {}", rejected.dir.display(), rejected.why)) - .size(LabelSize::XSmall), - ) - }) - .collect(); - v_flex() - .gap_2() - .when(rows.is_empty() && rejected.is_empty(), |view| { - view.child(Label::new("No plugins installed.").color(Color::Muted)) - }) - .children(rows) - .children(rejected) - .into_any_element() - } - } - }; - v_flex() - .id(format!("settings-content-{}", page.slug())) - .flex_1() - .min_w_0() - .h_full() - .overflow_y_scroll() - .items_center() - .child( - v_flex() - .w_full() - .max_w(px(680.)) - .p_6() - .gap_5() - .child(Label::new(page.title()).size(LabelSize::Large)) - .when_some(self.settings.unreadable().map(str::to_owned), |view, error| { - view.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) - }) - .child(body), - ) - .into_any_element() - } } impl Focusable for Zeddy { @@ -3461,48 +2778,30 @@ impl Render for Zeddy { cx.listener(|this, action: &Action, window, cx| this.act(action.clone(), window, cx)); let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); - let workspace = - v_flex().flex_1().h_full().overflow_hidden().bg(workspace_background).child( - if self.settings_open { - self.settings_workspace(cx) - } else { - self.workspace_pane(window, cx) - }, - ); + let workspace = v_flex() + .flex_1() + .h_full() + .overflow_hidden() + .bg(workspace_background) + .child(self.workspace_pane(window, cx)); - let body = if self.settings_open { - h_flex() + let body = match self.mode { + Mode::Sidebar => h_flex() .size_full() .child(chrome::sidebar::render( &sidebar_spaces, switcher, - new_item, emit.clone(), self.sidebar_width, cx, )) .child(workspace) - .into_any_element() - } else { - match self.mode { - Mode::Sidebar => h_flex() - .size_full() - .child(chrome::sidebar::render( - &sidebar_spaces, - switcher, - new_item, - emit.clone(), - self.sidebar_width, - cx, - )) - .child(workspace) - .into_any_element(), - Mode::Tabs => v_flex() - .size_full() - .child(chrome::tabs::render(chrome_entries, switcher, new_item, emit, cx)) - .child(workspace) - .into_any_element(), - } + .into_any_element(), + Mode::Tabs => v_flex() + .size_full() + .child(chrome::tabs::render(chrome_entries, switcher, new_item, emit, cx)) + .child(workspace) + .into_any_element(), }; let command_palette = self.command_palette(cx); @@ -3515,8 +2814,6 @@ impl Render for Zeddy { "RenameSpace" } else if self.command_palette_open { "CommandPalette" - } else if self.settings_open { - "Chartr Settings" } else { "Chartr" }) @@ -3583,9 +2880,9 @@ impl Render for Zeddy { .on_action(cx.listener(|this, _: &actions::workspace::NewTerminal, window, cx| { this.act(Action::New, window, cx) })) - .on_action( - cx.listener(|this, _: &actions::settings::Open, _, cx| this.open_settings(cx)), - ) + .on_action(cx.listener(|this, _: &actions::settings::Open, window, cx| { + this.open_settings(window, cx) + })) .on_action(cx.listener(|this, _: &actions::command_palette::Toggle, window, cx| { this.toggle_command_palette(window, cx) })) @@ -3596,10 +2893,6 @@ impl Render for Zeddy { } } -fn setting_label(label: &'static str) -> AnyElement { - Label::new(label).size(LabelSize::Small).color(Color::Muted).into_any_element() -} - fn pane_drop_direction_for_drag( event: &DragMoveEvent, ) -> Option> { @@ -3761,14 +3054,6 @@ fn pane_controls( .into_any_element() } -fn setting_value(label: &'static str, value: String) -> AnyElement { - v_flex() - .gap_1() - .child(setting_label(label)) - .child(Label::new(value).size(LabelSize::Small)) - .into_any_element() -} - fn load_registry(cwd: &std::path::Path) -> (Option, Option) { let file = match spaces::spaces_file() { Ok(file) => file, @@ -3813,7 +3098,7 @@ fn terminal( cursor: theme.colors().terminal_foreground, }; - v_flex().size_full().p_2().child(TerminalElement::new( + v_flex().size_full().p_2().bg(theme.colors().terminal_background).child(TerminalElement::new( screen, colors, appearance, diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 138447af..b1dd1303 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -61,8 +61,7 @@ pub enum Action { LocateSpace { space: EntityId }, NewInSpace { space: EntityId }, New, - ToggleMode, - ToggleSidebarScope, + OpenSettings, } /// How a chrome reports what the user did. @@ -142,6 +141,7 @@ pub fn status_indicator( status: Option, process_running: bool, ended: bool, + grouped: bool, space: &str, key: ItemId, cx: &App, @@ -152,6 +152,9 @@ pub fn status_indicator( if ended { return slot().child(icon(IconName::XCircle, Color::Error)).into_any_element(); } + if grouped { + return slot().child(icon(IconName::Split, Color::Muted)).into_any_element(); + } match status { Some(SessionStatus::Working) => { diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 8a20b3b1..0ed06e03 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -4,8 +4,8 @@ //! tab cannot hold — the agent's name under the title, and a close button that //! is not fighting the title for space — so this chrome shows them. -use gpui::{MouseButton, Role, deferred}; -use ui::{Tooltip, prelude::*}; +use gpui::{Anchor, MouseButton, Role, deferred}; +use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use super::Emit; @@ -24,7 +24,6 @@ pub const MAX_WIDTH: f32 = 480.; pub fn render( spaces: &[SpaceEntries], space_switcher: AnyElement, - new_item: AnyElement, on: Emit, width: f32, cx: &App, @@ -32,15 +31,13 @@ pub fn render( let colors = cx.theme().colors(); let mut groups = Vec::new(); let mut index = 0; - for space in spaces { + for (space_index, space) in spaces.iter().enumerate() { let add = on.clone(); - let close = on.clone(); - let rename = on.clone(); - let locate = on.clone(); + let actions = on.clone(); let space_id = space.id; - let close_space = space.id; - let rename_space = space.id; - let locate_space = space.id; + let action_space = space.id; + let removable = space.removable; + let available = space.available; groups.push( h_flex() .group("space-heading") @@ -53,48 +50,74 @@ pub fn render( h_flex() .gap_px() .child( - IconButton::new(("new-in-space", index), IconName::Plus) + IconButton::new(("new-in-space", space_index), IconName::Plus) .icon_size(IconSize::XSmall) .tooltip(Tooltip::text("New session in this space")) .on_click(move |_, window, cx| { add(Action::NewInSpace { space: space_id }, window, cx) }), ) - .when(space.removable, |controls| { + .when(removable || !available, |controls| { controls.child( - IconButton::new(("rename-space", index), IconName::Pencil) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Rename Space")) - .on_click(move |_, window, cx| { - rename( - Action::RenameSpace { space: rename_space }, - window, - cx, + PopoverMenu::new(format!("space-actions-{space_index}")) + .trigger_with_tooltip( + IconButton::new( + ("space-actions-trigger", space_index), + IconName::Ellipsis, ) - }), - ) - }) - .when(!space.available, |controls| { - controls.child( - IconButton::new(("locate-space", index), IconName::FolderOpen) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Locate Space Folder")) - .on_click(move |_, window, cx| { - locate( - Action::LocateSpace { space: locate_space }, - window, - cx, - ) - }), - ) - }) - .when(space.removable, |controls| { - controls.child( - IconButton::new(("close-space", index), IconName::Close) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Close Space")) - .on_click(move |_, window, cx| { - close(Action::CloseSpace { space: close_space }, window, cx) + .icon_size(IconSize::XSmall), + Tooltip::text("Space Actions"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let rename = actions.clone(); + let locate = actions.clone(); + let close = actions.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + let menu = menu.when(!available, |menu| { + menu.entry( + "Locate Space Folder", + None, + move |window, cx| { + locate( + Action::LocateSpace { + space: action_space, + }, + window, + cx, + ) + }, + ) + }); + menu.when(removable, |menu| { + let menu = menu.entry( + "Rename Space", + None, + move |window, cx| { + rename( + Action::RenameSpace { + space: action_space, + }, + window, + cx, + ) + }, + ); + menu.separator().entry( + "Close Space", + None, + move |window, cx| { + close( + Action::CloseSpace { + space: action_space, + }, + window, + cx, + ) + }, + ) + }) + })) }), ) }), @@ -119,7 +142,7 @@ pub fn render( .bg(colors.panel_background) .border_r_1() .border_color(colors.border) - .child(header(space_switcher, new_item, on.clone())) + .child(header(space_switcher, on.clone())) .child(v_flex().id("sessions").flex_1().overflow_y_scroll().p_1().gap_px().children(groups)) .child(deferred( div() @@ -138,33 +161,21 @@ pub fn render( )) } -fn header(space_switcher: AnyElement, new_item: AnyElement, on: Emit) -> impl IntoElement { - let toggle = on.clone(); - let scope = on.clone(); +fn header(space_switcher: AnyElement, on: Emit) -> impl IntoElement { + let settings = on; h_flex() .h(px(36.)) .px_2() .gap_1() .justify_between() - .child(div().min_w_0().flex_1().child(space_switcher)) + .child(h_flex().min_w_0().flex_1().child(space_switcher)) .child( - h_flex() - .gap_px() - .child(new_item) - .child( - IconButton::new("toggle-space-scope", IconName::ListTree) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Show all or active space")) - .on_click(move |_, window, cx| { - scope(Action::ToggleSidebarScope, window, cx) - }), - ) - .child( - IconButton::new("toggle-mode", IconName::Tab) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Switch to tabs")) - .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), - ), + h_flex().gap_px().child( + IconButton::new("open-settings", IconName::Settings) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Settings")) + .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), + ), ) } @@ -204,7 +215,10 @@ fn row( }) .aria_selected(selected) .group("session") - .h(px(38.)) + // The close button's standard Zed control height is the row's natural + // minimum. Let content establish that compact height, then keep it + // from flex-shrinking further when the list scrolls. + .flex_none() .px_2() .gap_2() .rounded_sm() @@ -220,6 +234,7 @@ fn row( entry.status, entry.process_running, entry.ended, + entry.grouped, &entry.space_key, entry.key, cx, diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index ff115c8f..255d05d7 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -20,7 +20,7 @@ pub fn render( cx: &App, ) -> impl IntoElement { let colors = cx.theme().colors(); - let toggle = on.clone(); + let settings = on.clone(); let active_index = entries.iter().position(|entry| entry.selected); h_flex() @@ -31,10 +31,11 @@ pub fn render( .border_b_1() .border_color(colors.border) .child( - div() + h_flex() .w(px(super::sidebar::DEFAULT_WIDTH)) .h_full() .flex_none() + .px_2() .border_r_1() .border_color(colors.border) .child(space_switcher), @@ -46,10 +47,10 @@ pub fn render( )) .child( h_flex().px_1().gap_px().flex_none().child(new_item).child( - IconButton::new("toggle-mode", IconName::Menu) + IconButton::new("open-settings", IconName::Settings) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Switch to sidebar")) - .on_click(move |_, window, cx| toggle(Action::ToggleMode, window, cx)), + .tooltip(Tooltip::text("Settings")) + .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), ), ) } @@ -154,6 +155,7 @@ fn tab( entry.status, entry.process_running, entry.ended, + entry.grouped, &entry.space_key, entry.key, cx, diff --git a/crates/zeddy/src/keymap.rs b/crates/zeddy/src/keymap.rs index bcbecc5c..f205a2e0 100644 --- a/crates/zeddy/src/keymap.rs +++ b/crates/zeddy/src/keymap.rs @@ -115,6 +115,8 @@ pub struct KeymapStore { problem: Option, } +impl gpui::Global for KeymapStore {} + impl KeymapStore { pub fn load(file: impl Into) -> Self { let file = file.into(); diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 67a49d3e..21a936ee 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -19,9 +19,11 @@ mod palette; mod persistence; mod session; mod settings; +mod settings_window; mod space; mod spaces; mod terminal; +mod text_input; mod web_plugin; mod workspace; @@ -35,9 +37,9 @@ fn main() { let keymap = keymap::keymap_file() .map(keymap::KeymapStore::load) .unwrap_or_else(|_| keymap::KeymapStore::bare()); - // `JustBase` loads no theme JSON, which means no asset source and no - // bundled themes. zeddy has no theme picker, so the built-in dark theme - // is the whole theming story until it does. + // Keep Zed's assets on the registry for the component and icon layer; + // `settings::init_themes` registers Chartr's theme catalog as ordinary + // Zed themes before applying the user-global selection. theme::init(theme::LoadThemes::All(Box::new(zed_assets::Assets)), cx); settings::init_themes(settings.resolved(), cx); if let Err(error) = zed_assets::Assets.load_fonts(cx) { @@ -53,6 +55,10 @@ fn main() { cx, ); actions::init(&keymap, cx); + text_input::init(cx); + settings_window::init(&keymap, cx); + cx.set_global(settings.clone()); + cx.set_global(keymap); // A build whose platform layer cannot rasterise glyphs paints every // quad and icon correctly and shows not one character. Saying so is @@ -96,9 +102,7 @@ fn main() { ..Default::default() }, |window, cx| { - let settings = settings.clone(); - let keymap = keymap.clone(); - let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), settings, keymap, cx)); + let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), cx)); window.focus(&view.read(cx).focus_handle(cx), cx); view }, diff --git a/crates/zeddy/src/mode.rs b/crates/zeddy/src/mode.rs index c6e9ca86..098261c2 100644 --- a/crates/zeddy/src/mode.rs +++ b/crates/zeddy/src/mode.rs @@ -20,24 +20,10 @@ pub enum Mode { Tabs, } -impl Mode { - pub fn toggled(self) -> Self { - match self { - Self::Sidebar => Self::Tabs, - Self::Tabs => Self::Sidebar, - } - } -} #[cfg(test)] mod tests { use super::*; - #[test] - fn toggling_twice_is_the_identity() { - assert_eq!(Mode::Sidebar.toggled().toggled(), Mode::Sidebar); - assert_eq!(Mode::Tabs.toggled().toggled(), Mode::Tabs); - } - #[test] fn the_two_modes_are_the_only_two() { assert_eq!(Mode::default(), Mode::Sidebar); diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index 4b00e9c0..7ba0ab86 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -12,6 +12,7 @@ use std::{ path::{Path, PathBuf}, }; +use gpui::BorrowAppContext; use serde::{Deserialize, Serialize}; use theme::{Appearance, GlobalTheme, SystemAppearance, Theme, ThemeRegistry}; @@ -242,6 +243,18 @@ pub struct SettingsStore { unreadable: Option, } +// Zed keeps settings as application-global state and has every window observe +// that store. Chartr does the same so the dedicated Settings window and every +// workspace always render one authoritative value. +impl gpui::Global for SettingsStore {} + +pub fn update_global( + cx: &mut gpui::App, + mutate: impl FnOnce(&mut SettingsContent), +) -> Result { + cx.update_global::(|store, _| store.update(mutate).cloned()) +} + impl SettingsStore { pub fn load(file: impl Into) -> Self { let file = file.into(); @@ -323,12 +336,27 @@ pub fn settings_file() -> Result { Ok(crate::spaces::config_root()?.join(SETTINGS_FILE)) } -/// Register Chartr's named semantic theme pair, then select the resolved -/// fixed/system variant. Both are ordinary Zed `Theme` values, so every Zed -/// component consumes the same tokens as Chartr's product views. +/// Register Chartr's theme catalog, then select the resolved fixed/system +/// variant. Every entry is an ordinary Zed `Theme`, so Chartr's terminal and +/// every Zed UI component consume one registry and one set of tokens. pub fn init_themes(settings: &ResolvedSettings, cx: &mut gpui::App) { let registry = ThemeRegistry::global(cx); - if let Ok(source) = registry.get("One Dark") { + let dark_source = registry.get("One Dark").ok(); + if let Some(dark_source) = &dark_source { + let light_source = registry + .get("One Light") + .map(|theme| (*theme).clone()) + .unwrap_or_else(|_| chartr_light(dark_source)); + registry.insert_themes(THEME_PALETTES.map(|palette| { + let source = if palette.appearance == Appearance::Light { + &light_source + } else { + dark_source.as_ref() + }; + catalog_theme(source, palette) + })); + } + if let Some(source) = dark_source { let mut dark = (*source).clone(); dark.id = "chartr_dark".to_owned(); dark.name = CHARTR_DARK.into(); @@ -338,6 +366,436 @@ pub fn init_themes(settings: &ResolvedSettings, cx: &mut gpui::App) { apply_theme(settings, cx); } +/// The same operator-facing catalog Chartr-rs exposes. Its palette values are +/// copied from that implementation: Ayu, Gruvbox, and One follow Zed's bundled +/// themes; Catppuccin follows its official semantic palette; VS Code follows +/// the workbench colors. Chartr only adapts those established values into +/// Zed's richer semantic token model. +#[derive(Clone, Copy)] +struct ThemePalette { + name: &'static str, + appearance: Appearance, + surface: u32, + sidebar: u32, + border: u32, + text: u32, + muted: u32, + card: u32, + card_open: u32, + ring: u32, + selected: u32, + hover: u32, + notice: u32, + accent: u32, + done: u32, + idle: u32, + quiet: u32, + terminal_foreground: u32, +} + +const THEME_PALETTES: [ThemePalette; 13] = [ + ThemePalette::new( + "Ayu Dark", + Appearance::Dark, + 0x0d1016, + 0x1f2127, + 0x3f4043, + 0xbfbdb6, + 0x8a8986, + 0x1f2127, + 0x3e4043, + 0x1b4a6e, + 0x3e4043, + 0x2d2f34, + 0xef7177, + 0x5ac1fe, + 0xaad84c, + 0xfeb454, + 0x696a6a, + 0xbfbdb6, + ), + ThemePalette::new( + "Ayu Light", + Appearance::Light, + 0xfcfcfc, + 0xececed, + 0xcfd1d2, + 0x5c6166, + 0x8b8e92, + 0xececed, + 0xcfd0d2, + 0xc4daf6, + 0xcfd0d2, + 0xdfe0e1, + 0xef7271, + 0x3b9ee5, + 0x85b304, + 0xf1ad49, + 0xa9acae, + 0x5c6166, + ), + ThemePalette::new( + "Ayu Mirage", + Appearance::Dark, + 0x242835, + 0x353944, + 0x53565d, + 0xcccac2, + 0x9a9a98, + 0x353944, + 0x53565d, + 0x24556f, + 0x53565d, + 0x43464f, + 0xf18779, + 0x72cffe, + 0xd5fe80, + 0xfecf72, + 0x7b7d7f, + 0xcccac2, + ), + ThemePalette::new( + "Catppuccin Frappé", + Appearance::Dark, + 0x303446, + 0x292c3c, + 0x51576d, + 0xc6d0f5, + 0xa5adce, + 0x414559, + 0x51576d, + 0xca9ee6, + 0x51576d, + 0x414559, + 0xe78284, + 0xca9ee6, + 0xa6d189, + 0xe5c890, + 0x737994, + 0xc6d0f5, + ), + ThemePalette::new( + "Catppuccin Latte", + Appearance::Light, + 0xeff1f5, + 0xe6e9ef, + 0xbcc0cc, + 0x4c4f69, + 0x6c6f85, + 0xccd0da, + 0xbcc0cc, + 0x8839ef, + 0xbcc0cc, + 0xccd0da, + 0xd20f39, + 0x8839ef, + 0x40a02b, + 0xdf8e1d, + 0x9ca0b0, + 0x4c4f69, + ), + ThemePalette::new( + "Catppuccin Macchiato", + Appearance::Dark, + 0x24273a, + 0x1e2030, + 0x494d64, + 0xcad3f5, + 0xa5adcb, + 0x363a4f, + 0x494d64, + 0xc6a0f6, + 0x494d64, + 0x363a4f, + 0xed8796, + 0xc6a0f6, + 0xa6da95, + 0xeed49f, + 0x6e738d, + 0xcad3f5, + ), + ThemePalette::new( + "Catppuccin Mocha", + Appearance::Dark, + 0x1e1e2e, + 0x181825, + 0x45475a, + 0xcdd6f4, + 0xa6adc8, + 0x313244, + 0x45475a, + 0xcba6f7, + 0x45475a, + 0x313244, + 0xf38ba8, + 0xcba6f7, + 0xa6e3a1, + 0xf9e2af, + 0x6c7086, + 0xcdd6f4, + ), + ThemePalette::new( + "Gruvbox Dark", + Appearance::Dark, + 0x282828, + 0x3a3735, + 0x5b534d, + 0xfbf1c7, + 0xc5b597, + 0x3a3735, + 0x5b524c, + 0x303a36, + 0x5b524c, + 0x494340, + 0xfb4a35, + 0x83a598, + 0xb7bb26, + 0xf9bd2f, + 0x998b78, + 0xebdbb2, + ), + ThemePalette::new( + "Gruvbox Light", + Appearance::Light, + 0xfbf1c7, + 0xecddb4, + 0xc8b899, + 0x282828, + 0x5f5650, + 0xecddb4, + 0xc8b899, + 0xadc5cc, + 0xc8b899, + 0xddcca7, + 0x9d0308, + 0x0b6678, + 0x797410, + 0xb57615, + 0x897b6e, + 0x282828, + ), + ThemePalette::new( + "One Dark", + Appearance::Dark, + 0x282c33, + 0x2f343e, + 0x464b57, + 0xdce0e5, + 0xa9afbc, + 0x2e343e, + 0x454a56, + 0x47679e, + 0x454a56, + 0x363c46, + 0xd07277, + 0x74ade8, + 0xa1c181, + 0xdec184, + 0x878a98, + 0xabb2bf, + ), + ThemePalette::new( + "One Light", + Appearance::Light, + 0xfafafa, + 0xebebec, + 0xc9c9ca, + 0x242529, + 0x58585a, + 0xebebec, + 0xcacaca, + 0x7d82e8, + 0xcacaca, + 0xdfdfe0, + 0xd36151, + 0x5c78e2, + 0x669f59, + 0xa48819, + 0x7e8086, + 0x2a2c33, + ), + ThemePalette::new( + "VSCode Dark Modern", + Appearance::Dark, + 0x1f1f1f, + 0x181818, + 0x2b2b2b, + 0xcccccc, + 0x9d9d9d, + 0x313131, + 0x313131, + 0x0078d4, + 0x313131, + 0x2b2b2b, + 0xf85149, + 0x0078d4, + 0x2ea043, + 0xe2c08d, + 0x6e7681, + 0xcccccc, + ), + ThemePalette::new( + "VSCode Dark Plus", + Appearance::Dark, + 0x1e1e1e, + 0x252526, + 0x3f3f46, + 0xd4d4d4, + 0x969696, + 0x2d2d30, + 0x37373d, + 0x007acc, + 0x37373d, + 0x2a2d2e, + 0xf44747, + 0x007acc, + 0x6a9955, + 0xdcdcaa, + 0x707070, + 0xd4d4d4, + ), +]; + +impl ThemePalette { + #[allow(clippy::too_many_arguments)] + const fn new( + name: &'static str, + appearance: Appearance, + surface: u32, + sidebar: u32, + border: u32, + text: u32, + muted: u32, + card: u32, + card_open: u32, + ring: u32, + selected: u32, + hover: u32, + notice: u32, + accent: u32, + done: u32, + idle: u32, + quiet: u32, + terminal_foreground: u32, + ) -> Self { + Self { + name, + appearance, + surface, + sidebar, + border, + text, + muted, + card, + card_open, + ring, + selected, + hover, + notice, + accent, + done, + idle, + quiet, + terminal_foreground, + } + } +} + +fn catalog_theme(source: &Theme, palette: ThemePalette) -> Theme { + let mut theme = source.clone(); + theme.id = + format!("chartr_catalog_{}", palette.name.to_ascii_lowercase().replace([' ', 'é'], "_")); + theme.name = palette.name.into(); + theme.appearance = palette.appearance; + + let color = |value| gpui::rgb(value).into(); + let surface = color(palette.surface); + let sidebar = color(palette.sidebar); + let border = color(palette.border); + let text = color(palette.text); + let muted = color(palette.muted); + let card = color(palette.card); + let card_open = color(palette.card_open); + let ring = color(palette.ring); + let selected = color(palette.selected); + let hover = color(palette.hover); + let notice = color(palette.notice); + let accent = color(palette.accent); + let done = color(palette.done); + let idle = color(palette.idle); + let quiet = color(palette.quiet); + let terminal_foreground = color(palette.terminal_foreground); + + let colors = &mut theme.styles.colors; + colors.background = surface; + colors.surface_background = sidebar; + colors.elevated_surface_background = card_open; + colors.element_background = card; + colors.element_hover = hover; + colors.element_active = selected; + colors.element_selected = selected; + colors.element_selection_background = selected; + colors.ghost_element_hover = hover; + colors.ghost_element_active = selected; + colors.ghost_element_selected = selected; + colors.drop_target_background = selected; + colors.drop_target_border = ring; + colors.border = border; + colors.border_variant = border; + colors.border_focused = ring; + colors.border_selected = ring; + colors.text = text; + colors.text_muted = muted; + colors.text_placeholder = muted; + colors.text_disabled = quiet; + colors.text_accent = accent; + colors.icon = text; + colors.icon_muted = muted; + colors.icon_placeholder = muted; + colors.icon_disabled = quiet; + colors.icon_accent = accent; + colors.title_bar_background = sidebar; + colors.title_bar_inactive_background = card; + colors.toolbar_background = sidebar; + colors.tab_bar_background = card; + colors.tab_inactive_background = card; + colors.tab_active_background = surface; + colors.panel_background = sidebar; + colors.panel_focused_border = ring; + colors.panel_indent_guide = border; + colors.panel_indent_guide_hover = muted; + colors.panel_indent_guide_active = ring; + colors.panel_overlay_background = card; + colors.panel_overlay_hover = hover; + colors.pane_group_border = border; + colors.editor_background = surface; + colors.editor_foreground = text; + colors.editor_gutter_background = surface; + colors.editor_subheader_background = card; + colors.terminal_background = surface; + colors.terminal_ansi_background = surface; + colors.terminal_foreground = terminal_foreground; + colors.terminal_bright_foreground = text; + colors.terminal_dim_foreground = muted; + colors.link_text_hover = accent; + colors.version_control_added = done; + colors.version_control_deleted = notice; + colors.version_control_modified = idle; + + let status = &mut theme.styles.status; + status.error = notice; + status.error_border = notice; + status.warning = idle; + status.warning_border = idle; + status.success = done; + status.success_border = done; + status.info = accent; + status.info_border = accent; + status.hidden = quiet; + status.ignored = quiet; + theme +} + fn chartr_light(dark: &Theme) -> Theme { let mut light = dark.clone(); light.id = "chartr_light".to_owned(); @@ -384,6 +842,11 @@ fn chartr_light(dark: &Theme) -> Theme { colors.editor_foreground = text; colors.editor_gutter_background = surface; colors.editor_subheader_background = raised; + colors.terminal_background = surface; + colors.terminal_ansi_background = surface; + colors.terminal_foreground = text; + colors.terminal_bright_foreground = text; + colors.terminal_dim_foreground = muted; light } @@ -403,6 +866,23 @@ pub fn apply_theme(settings: &ResolvedSettings, cx: &mut gpui::App) { #[cfg(test)] mod tests { use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn the_chartr_rs_theme_catalog_is_registered_as_zed_themes(cx: &mut TestAppContext) { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + init_themes(&ResolvedSettings::default(), cx); + let registry = ThemeRegistry::global(cx); + + for palette in THEME_PALETTES { + let registered = registry.get(palette.name).unwrap(); + assert_eq!(registered.appearance, palette.appearance); + } + assert_eq!(registry.get(CHARTR_DARK).unwrap().appearance, Appearance::Dark); + assert_eq!(registry.get(CHARTR_LIGHT).unwrap().appearance, Appearance::Light); + }); + } #[test] fn a_missing_file_resolves_to_fixed_chartr_dark() { diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs new file mode 100644 index 00000000..ef6a895c --- /dev/null +++ b/crates/zeddy/src/settings_window.rs @@ -0,0 +1,1317 @@ +//! Chartr's singleton, application-wide Settings window. +//! +//! This follows Zed's `SettingsWindow` boundary: opening Settings focuses the +//! existing app-wide window, global settings notify every workspace live, and +//! the originating workspace is retained only for operations that truly need +//! runtime state (the backend and plugin catalog). + +use gpui::{ + Anchor, AnyView, App, Bounds, Context, DefiniteLength, Entity, FocusHandle, Focusable, + FontWeight, KeyBinding, PathPromptOptions, Render, Role, WeakEntity, Window, WindowBounds, + WindowHandle, WindowOptions, actions, px, size, +}; +use ui::{ + Banner, Button, ColumnWidthConfig, ContextMenu, DropdownMenu, DropdownStyle, Icon, IconButton, + PopoverMenu, RedistributableColumnsState, Severity, Table, TableResizeBehavior, Tooltip, + prelude::*, +}; + +use crate::{ + app::Zeddy, + fonts::Fonts, + keymap::{KeymapAction, KeymapStore}, + mode::Mode, + persistence::SidebarScope, + settings::{ + self, AppearanceContent, GeneralContent, ResolvedSettings, SettingsPage, SettingsStore, + TerminalContent, ThemeMode, + }, +}; + +actions!(settings_window, [Close]); + +#[derive(Clone, Copy)] +enum ThemeTarget { + Fixed, + Light, + Dark, +} + +pub fn init(keymap: &KeymapStore, cx: &mut App) { + #[cfg(target_os = "macos")] + cx.bind_keys([KeyBinding::new("cmd-w", Close, Some("ChartrSettings"))]); + + #[cfg(not(target_os = "macos"))] + cx.bind_keys([KeyBinding::new("ctrl-w", Close, Some("ChartrSettings"))]); + + cx.bind_keys([KeyBinding::new( + keymap.key(KeymapAction::OpenSettings), + crate::actions::settings::Open, + Some("ChartrSettings"), + )]); +} + +/// Focus Zed-style: one Settings window for the application, never one per +/// workspace. Reopening also retargets workspace-scoped controls to the most +/// recent caller. +pub fn open(original_window: WindowHandle, original: WeakEntity, cx: &mut App) { + open_with_origin(Some(original_window), original, cx); +} + +fn open_with_origin( + original_window: Option>, + original: WeakEntity, + cx: &mut App, +) { + let existing = cx.windows().into_iter().find_map(|window| window.downcast::()); + if let Some(existing) = existing { + existing + .update(cx, |settings, window, cx| { + settings.original_window = original_window; + settings.original = original; + window.activate_window(); + cx.notify(); + }) + .ok(); + return; + } + + // Like Zed, defer creation so the originating workspace action is off the + // stack before GPUI installs another root view. + cx.defer(move |cx| { + let bounds = Bounds::centered(None, size(px(900.), px(680.)), cx); + let opened = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some("Chartr — Settings".into()), + ..Default::default() + }), + focus: true, + show: true, + is_movable: true, + kind: gpui::WindowKind::Normal, + window_background: cx.theme().window_background_appearance(), + window_min_size: Some(size(px(640.), px(420.))), + ..Default::default() + }, + |window, cx| { + let view = cx.new(|cx| SettingsWindow::new(original_window, original, window, cx)); + window.focus(&view.read(cx).focus_handle(cx), cx); + view + }, + ); + if let Err(error) = opened { + eprintln!("Chartr could not open Settings: {error}"); + } + }); +} + +pub struct SettingsWindow { + original_window: Option>, + original: WeakEntity, + page: SettingsPage, + plugin_settings: Option<(String, AnyView)>, + recording_keymap: Option, + keymap_restart_required: bool, + hotkey_widths: Entity, + focus: FocusHandle, + problem: Option, +} + +impl SettingsWindow { + fn new( + original_window: Option>, + original: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + cx.observe_global_in::(window, |_, _, cx| cx.notify()).detach(); + cx.observe_global_in::(window, |_, _, cx| cx.notify()).detach(); + cx.on_window_closed(|cx, _| { + if let Some(settings) = + cx.windows().into_iter().find_map(|window| window.downcast::()) + && cx.windows().len() == 1 + { + cx.update_window(*settings, |_, window, _| window.remove_window()).ok(); + } + }) + .detach(); + Self { + original_window, + original, + page: SettingsPage::default(), + plugin_settings: None, + recording_keymap: None, + keymap_restart_required: false, + hotkey_widths: cx.new(|_| { + RedistributableColumnsState::new( + 2, + vec![DefiniteLength::Fraction(0.68), DefiniteLength::Fraction(0.32)], + vec![TableResizeBehavior::Resizable, TableResizeBehavior::Resizable], + ) + }), + focus: cx.focus_handle(), + problem: None, + } + } + + fn update_settings( + &mut self, + mutate: impl FnOnce(&mut settings::SettingsContent), + apply_theme: bool, + apply_fonts: bool, + cx: &mut Context, + ) { + match settings::update_global(cx, mutate) { + Ok(resolved) => { + if apply_theme { + settings::apply_theme(&resolved, cx); + } + if apply_fonts { + theme::set_theme_settings_provider( + Box::new(Fonts::from_settings(&resolved)), + cx, + ); + } + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + } + + fn set_terminate_on_exit(&mut self, enabled: bool, cx: &mut Context) { + self.update_settings( + |content| { + content + .general + .get_or_insert_with(GeneralContent::default) + .terminate_sessions_on_exit = Some(enabled); + }, + false, + false, + cx, + ); + } + + fn set_theme_mode(&mut self, mode: ThemeMode, cx: &mut Context) { + self.update_settings( + move |content| { + let appearance = content.appearance.get_or_insert_with(AppearanceContent::default); + appearance.theme_mode = Some(mode); + }, + true, + true, + cx, + ); + } + + fn set_theme(&mut self, target: ThemeTarget, theme: String, cx: &mut Context) { + self.update_settings( + move |content| { + let appearance = content.appearance.get_or_insert_with(AppearanceContent::default); + match target { + ThemeTarget::Fixed => { + appearance.theme_mode = Some(ThemeMode::Fixed); + appearance.fixed_theme = Some(theme); + } + ThemeTarget::Light => { + appearance.theme_mode = Some(ThemeMode::System); + appearance.light_theme = Some(theme); + } + ThemeTarget::Dark => { + appearance.theme_mode = Some(ThemeMode::System); + appearance.dark_theme = Some(theme); + } + } + }, + true, + true, + cx, + ); + } + + fn set_ui_font(&mut self, family: String, cx: &mut Context) { + self.update_settings( + move |content| { + content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_family = + Some(family); + }, + false, + true, + cx, + ); + } + + fn adjust_ui_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = cx.global::().resolved().ui_font_size; + self.update_settings( + move |content| { + content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_size = + Some((current + delta).clamp(8., 32.)); + }, + false, + true, + cx, + ); + } + + fn set_terminal_font(&mut self, family: String, cx: &mut Context) { + self.update_settings( + move |content| { + content.terminal.get_or_insert_with(TerminalContent::default).font_family = + Some(family); + }, + false, + false, + cx, + ); + } + + fn adjust_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = cx.global::().resolved().terminal_font_size; + self.update_settings( + move |content| { + content.terminal.get_or_insert_with(TerminalContent::default).font_size = + Some((current + delta).clamp(8., 72.)); + }, + false, + false, + cx, + ); + } + + fn pick_free_sessions_directory(&mut self, cx: &mut Context) { + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Use for Free sessions".into()), + }); + cx.spawn(async move |this, cx| { + let outcome = chosen.await; + let _ = this.update(cx, |this, cx| match outcome { + Ok(Ok(Some(paths))) if !paths.is_empty() => { + let path = paths[0].clone(); + match settings::update_global(cx, |content| { + content + .terminal + .get_or_insert_with(TerminalContent::default) + .ad_hoc_directory = Some(path.clone()); + }) { + Ok(_) => { + if let Some(origin) = this.original.upgrade() { + origin.update(cx, |origin, cx| { + origin.settings_set_free_sessions_directory(path, cx) + }); + } + this.problem = None; + } + Err(error) => this.problem = Some(error.to_string()), + } + cx.notify(); + } + Ok(Ok(_)) | Err(_) => {} + Ok(Err(error)) => { + this.problem = Some(error.to_string()); + cx.notify(); + } + }); + }) + .detach(); + } + + fn cycle_page(&mut self, backwards: bool, cx: &mut Context) { + let current = SettingsPage::ALL.iter().position(|page| *page == self.page).unwrap_or(0); + let next = if backwards { + current.checked_sub(1).unwrap_or(SettingsPage::ALL.len() - 1) + } else { + (current + 1) % SettingsPage::ALL.len() + }; + self.page = SettingsPage::ALL[next]; + self.plugin_settings = None; + cx.notify(); + } + + fn on_key(&mut self, event: &gpui::KeyDownEvent, _: &mut Window, cx: &mut Context) { + if let Some(action) = self.recording_keymap { + cx.stop_propagation(); + if event.keystroke.key == "escape" { + self.recording_keymap = None; + cx.notify(); + return; + } + if matches!( + event.keystroke.key.as_str(), + "shift" | "control" | "alt" | "cmd" | "super" | "fn" + ) { + return; + } + let key = event.keystroke.unparse(); + match cx.update_global::(|keymap, _| keymap.set(action, key)) { + Ok(()) => { + self.recording_keymap = None; + self.keymap_restart_required = true; + self.problem = None; + } + Err(error) => self.problem = Some(error.to_string()), + } + cx.notify(); + return; + } + if event.keystroke.modifiers.control && event.keystroke.key == "tab" { + cx.stop_propagation(); + self.cycle_page(event.keystroke.modifiers.shift, cx); + } + } + + fn set_plugin_enabled(&mut self, plugin: String, enabled: bool, cx: &mut Context) { + let Some(origin) = self.original.upgrade() else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + cx.notify(); + return; + }; + match origin.update(cx, |origin, cx| { + origin.settings_set_plugin_enabled(plugin.clone(), enabled, cx) + }) { + Ok(()) => { + if !enabled && self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { + self.plugin_settings = None; + } + self.problem = None; + } + Err(error) => self.problem = Some(error), + } + cx.notify(); + } + + fn set_plugin_unsafe(&mut self, plugin: String, enabled: bool, cx: &mut Context) { + let Some(origin) = self.original.upgrade() else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + cx.notify(); + return; + }; + match origin + .update(cx, |origin, cx| origin.settings_set_plugin_unsafe(plugin.clone(), enabled, cx)) + { + Ok(()) => { + if self.plugin_settings.as_ref().is_some_and(|(id, _)| id == &plugin) { + self.plugin_settings = None; + } + self.problem = None; + } + Err(error) => self.problem = Some(error), + } + cx.notify(); + } + + fn open_plugin_settings( + &mut self, + plugin: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(origin) = self.original.upgrade() else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + cx.notify(); + return; + }; + let view = origin.update(cx, |origin, cx| origin.settings_plugin_view(&plugin, window, cx)); + if let Some(view) = view { + self.plugin_settings = Some((plugin, view)); + self.problem = None; + } + cx.notify(); + } + + fn backend_label(&self, cx: &App) -> String { + self.original + .upgrade() + .map(|origin| origin.read(cx).settings_backend_label()) + .unwrap_or_else(|| "Workspace unavailable".to_owned()) + } + + fn sidebar_scope(&self, cx: &App) -> Option { + self.original.upgrade().map(|origin| origin.read(cx).settings_sidebar_scope()) + } + + fn mode(&self, cx: &App) -> Option { + self.original.upgrade().map(|origin| origin.read(cx).settings_mode()) + } + + fn set_mode(&mut self, mode: Mode, cx: &mut Context) { + if let Some(origin) = self.original.upgrade() { + origin.update(cx, |origin, cx| origin.settings_set_mode(mode, cx)); + self.problem = None; + } else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + } + cx.notify(); + } + + fn set_sidebar_scope(&mut self, scope: SidebarScope, cx: &mut Context) { + if let Some(origin) = self.original.upgrade() { + origin.update(cx, |origin, cx| origin.settings_set_sidebar_scope(scope, cx)); + self.problem = None; + } else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + } + cx.notify(); + } + + fn retry_backend(&mut self, cx: &mut Context) { + if let Some(origin) = self.original.upgrade() { + origin.update(cx, |origin, cx| origin.settings_retry_backend(cx)); + self.problem = None; + } else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + } + cx.notify(); + } + + fn restart_backend(&mut self, cx: &mut Context) { + let Some(origin) = self.original_window else { + self.problem = Some("The originating Chartr window is no longer available.".into()); + cx.notify(); + return; + }; + if origin + .update(cx, |origin, window, cx| origin.settings_restart_backend(window, cx)) + .is_err() + { + self.problem = Some("The originating Chartr window is no longer available.".into()); + } else { + self.problem = None; + } + cx.notify(); + } + + fn settings(&self, cx: &App) -> ResolvedSettings { + cx.global::().resolved().clone() + } + + fn content(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + if self.page == SettingsPage::Plugins + && let Some((plugin, view)) = self.plugin_settings.as_ref() + { + let back = cx.listener(|this, _, _, cx| { + this.plugin_settings = None; + cx.notify(); + }); + return v_flex() + .gap_3() + .child(Button::new("plugin-settings-back", "Back to plugins").on_click(back)) + .child(Label::new(plugin.clone()).size(LabelSize::XSmall).color(Color::Muted)) + .child(div().min_h(px(320.)).child(view.clone())) + .into_any_element(); + } + match self.page { + SettingsPage::General => self.general_page(cx), + SettingsPage::Appearance => self.appearance_page(window, cx), + SettingsPage::Terminal => self.terminal_page(cx), + SettingsPage::Hotkeys => self.hotkeys_page(cx), + SettingsPage::Plugins => self.plugins_page(window, cx), + } + } + + fn general_page(&mut self, cx: &mut Context) -> AnyElement { + let terminate = self.settings(cx).terminate_sessions_on_exit; + let mode = self.mode(cx); + let sidebar_scope = self.sidebar_scope(cx); + let runtime_available = mode.is_some() && sidebar_scope.is_some(); + let mode = mode.unwrap_or_default(); + let sidebar_scope = sidebar_scope.unwrap_or_default(); + let toggle = cx.listener(move |this, _, _, cx| this.set_terminate_on_exit(!terminate, cx)); + let use_sidebar = cx.listener(|this, _, _, cx| this.set_mode(Mode::Sidebar, cx)); + let use_tabs = cx.listener(|this, _, _, cx| this.set_mode(Mode::Tabs, cx)); + let show_all = + cx.listener(|this, _, _, cx| this.set_sidebar_scope(SidebarScope::AllSpaces, cx)); + let show_active = + cx.listener(|this, _, _, cx| this.set_sidebar_scope(SidebarScope::ActiveSpace, cx)); + v_flex() + .gap_4() + .child(Label::new("Chartr").size(LabelSize::Large)) + .child( + Label::new(format!( + "Version {} · configuration namespace chartr-zeddy", + env!("CARGO_PKG_VERSION") + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex() + .justify_between() + .gap_4() + .child( + v_flex() + .child(Label::new("Terminate sessions on exit").size(LabelSize::Small)) + .child( + Label::new("Normal app exit detaches and leaves sessions running.") + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new( + "terminate-sessions-on-exit", + if terminate { "On" } else { "Off" }, + ) + .toggle_state(terminate) + .on_click(toggle), + ), + ) + .child(setting_label("Presentation")) + .child( + h_flex() + .justify_between() + .gap_4() + .child( + v_flex().child(Label::new("Session list").size(LabelSize::Small)).child( + Label::new("Show sessions in a sidebar or a tab strip.") + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("presentation-sidebar", "Sidebar") + .disabled(!runtime_available) + .toggle_state(mode == Mode::Sidebar) + .on_click(use_sidebar), + ) + .child( + Button::new("presentation-tabs", "Tabbed") + .disabled(!runtime_available) + .toggle_state(mode == Mode::Tabs) + .on_click(use_tabs), + ), + ), + ) + .child(setting_label("Sidebar")) + .child( + h_flex() + .justify_between() + .gap_4() + .child( + v_flex().child(Label::new("Spaces shown").size(LabelSize::Small)).child( + Label::new("Show every space or only the currently active space.") + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("sidebar-all-spaces", "All spaces") + .disabled(!runtime_available) + .toggle_state(sidebar_scope == SidebarScope::AllSpaces) + .on_click(show_all), + ) + .child( + Button::new("sidebar-active-space", "Active space only") + .disabled(!runtime_available) + .toggle_state(sidebar_scope == SidebarScope::ActiveSpace) + .on_click(show_active), + ), + ), + ) + .into_any_element() + } + + fn theme_dropdown( + &self, + id: &'static str, + label: &'static str, + current: String, + target: ThemeTarget, + themes: Vec, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let weak = cx.weak_entity(); + let selected = current.clone(); + let menu = ContextMenu::build(window, cx, move |menu, _, _| { + let mut menu = menu; + let has_dark = themes.iter().any(|theme| theme.appearance == theme::Appearance::Dark); + let has_light = themes.iter().any(|theme| theme.appearance == theme::Appearance::Light); + + for (appearance, heading) in [ + (theme::Appearance::Dark, "Dark themes"), + (theme::Appearance::Light, "Light themes"), + ] { + let choices: Vec<_> = + themes.iter().filter(|theme| theme.appearance == appearance).collect(); + if choices.is_empty() { + continue; + } + if has_dark && has_light { + if appearance == theme::Appearance::Light { + menu = menu.separator(); + } + menu = menu.header(heading); + } + for choice in choices { + let name = choice.name.to_string(); + let checked = name == selected; + let update = weak.clone(); + menu = menu.toggleable_entry( + name.clone(), + checked, + IconPosition::End, + None, + move |_, cx| { + let name = name.clone(); + let _ = update.update(cx, |this, cx| this.set_theme(target, name, cx)); + }, + ); + } + } + menu + }); + + DropdownMenu::new(id, current, menu) + .style(DropdownStyle::Outlined) + .attach(Anchor::BottomLeft) + .aria_label(label) + .into_any_element() + } + + fn appearance_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let settings = self.settings(cx); + let mode = settings.theme_mode; + let mut themes = theme::ThemeRegistry::global(cx).list(); + themes.sort_unstable_by(|a, b| { + a.appearance.is_light().cmp(&b.appearance.is_light()).then(a.name.cmp(&b.name)) + }); + let light_themes = themes + .iter() + .filter(|theme| theme.appearance == theme::Appearance::Light) + .cloned() + .collect(); + let dark_themes = themes + .iter() + .filter(|theme| theme.appearance == theme::Appearance::Dark) + .cloned() + .collect(); + let fixed_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::Fixed, cx)); + let system_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::System, cx)); + let font = cx.weak_entity(); + let smaller = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(-1., cx)); + let larger = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(1., cx)); + let fixed_picker = self.theme_dropdown( + "fixed-theme-menu", + "Theme", + settings.fixed_theme.clone(), + ThemeTarget::Fixed, + themes, + window, + cx, + ); + let light_picker = self.theme_dropdown( + "light-theme-menu", + "Light theme", + settings.light_theme.clone(), + ThemeTarget::Light, + light_themes, + window, + cx, + ); + let dark_picker = self.theme_dropdown( + "dark-theme-menu", + "Dark theme", + settings.dark_theme.clone(), + ThemeTarget::Dark, + dark_themes, + window, + cx, + ); + v_flex() + .gap_3() + .child(setting_label("Theme mode")) + .child( + h_flex() + .gap_1() + .child( + Button::new("theme-fixed", "Fixed") + .toggle_state(mode == ThemeMode::Fixed) + .on_click(fixed_mode), + ) + .child( + Button::new("theme-system", "Match system") + .toggle_state(mode == ThemeMode::System) + .on_click(system_mode), + ), + ) + .when(mode == ThemeMode::Fixed, |view| { + view.child(setting_label("Theme")).child(fixed_picker) + }) + .when(mode == ThemeMode::System, |view| { + view.child( + h_flex() + .gap_6() + .child( + v_flex() + .gap_1() + .child(setting_label("Light theme")) + .child(light_picker), + ) + .child( + v_flex().gap_1().child(setting_label("Dark theme")).child(dark_picker), + ), + ) + }) + .child(setting_label("Interface font")) + .child( + h_flex() + .gap_1() + .child( + PopoverMenu::new("ui-font-menu") + .trigger( + Button::new("ui-font-family", settings.ui_font_family) + .end_icon(Icon::new(IconName::ChevronDown)), + ) + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + ["IBM Plex Sans", ".ZedSans", "System UI"].into_iter().fold( + menu, + |menu, family| { + let set = font.clone(); + menu.entry(family, None, move |_, cx| { + let _ = set.update(cx, |this, cx| { + this.set_ui_font(family.to_owned(), cx) + }); + }) + }, + ) + })) + }), + ) + .child( + IconButton::new("ui-font-smaller", IconName::Dash) + .tooltip(Tooltip::text("Decrease interface font size")) + .on_click(smaller), + ) + .child( + Label::new(format!("{} px", settings.ui_font_size)).size(LabelSize::Small), + ) + .child( + IconButton::new("ui-font-larger", IconName::Plus) + .tooltip(Tooltip::text("Increase interface font size")) + .on_click(larger), + ), + ) + .into_any_element() + } + + fn terminal_page(&mut self, cx: &mut Context) -> AnyElement { + let settings = self.settings(cx); + let font = cx.weak_entity(); + let smaller = cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(-1., cx)); + let larger = cx.listener(|this, _, _, cx| this.adjust_terminal_font_size(1., cx)); + let choose_directory = cx.listener(|this, _, _, cx| this.pick_free_sessions_directory(cx)); + let retry = cx.listener(|this, _, _, cx| this.retry_backend(cx)); + let restart = cx.listener(|this, _, _, cx| this.restart_backend(cx)); + let runtime_available = self.original.upgrade().is_some(); + v_flex() + .gap_3() + .child(setting_label("Terminal font")) + .child( + h_flex() + .gap_1() + .child( + PopoverMenu::new("terminal-font-menu") + .trigger( + Button::new("terminal-font-family", settings.terminal_font_family) + .end_icon(Icon::new(IconName::ChevronDown)), + ) + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + ["IBM Plex Mono", "Lilex", ".ZedMono"].into_iter().fold( + menu, + |menu, family| { + let set = font.clone(); + menu.entry(family, None, move |_, cx| { + let _ = set.update(cx, |this, cx| { + this.set_terminal_font(family.to_owned(), cx) + }); + }) + }, + ) + })) + }), + ) + .child( + IconButton::new("terminal-font-smaller", IconName::Dash) + .tooltip(Tooltip::text("Decrease terminal font size")) + .on_click(smaller), + ) + .child( + Label::new(format!("{} px", settings.terminal_font_size)) + .size(LabelSize::Small), + ) + .child( + IconButton::new("terminal-font-larger", IconName::Plus) + .tooltip(Tooltip::text("Increase terminal font size")) + .on_click(larger), + ), + ) + .child(setting_label("Free sessions directory")) + .child( + Button::new( + "choose-free-sessions-directory", + settings.ad_hoc_directory.as_ref().map_or_else( + || "Home directory".to_owned(), + |path| path.display().to_string(), + ), + ) + .on_click(choose_directory), + ) + .child(setting_value("Backend", self.backend_label(cx))) + .child( + h_flex() + .gap_1() + .child( + Button::new("settings-retry-backend", "Retry") + .disabled(!runtime_available) + .on_click(retry), + ) + .child( + Button::new("settings-restart-backend", "Restart Backend") + .disabled(!runtime_available) + .on_click(restart), + ), + ) + .into_any_element() + } + + fn hotkeys_page(&mut self, cx: &mut Context) -> AnyElement { + let recording = self.recording_keymap; + let keymap = cx.global::(); + let table = KeymapAction::ALL.into_iter().fold( + Table::new(2) + .striped() + .width_config(ColumnWidthConfig::redistributable(self.hotkey_widths.clone())) + .header(vec!["Action", "Shortcut"]), + |table, action| { + let capture = cx.listener(move |this, _, _, cx| { + this.recording_keymap = Some(action); + this.problem = None; + cx.notify(); + }); + table.row(vec![ + Label::new(action.title()).size(LabelSize::Small).into_any_element(), + Button::new( + format!("record-hotkey-{}", action.id()), + if recording == Some(action) { + "Press shortcut…".to_owned() + } else { + keymap.key(action).to_owned() + }, + ) + .label_size(LabelSize::Default) + .toggle_state(recording == Some(action)) + .on_click(capture) + .into_any_element(), + ]) + }, + ); + let keymap_problem = keymap.problem().map(str::to_owned); + v_flex() + .gap_2() + .when_some(keymap_problem, |view, problem| { + view.child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(problem).size(LabelSize::Small)), + ) + }) + .when(self.keymap_restart_required, |view| { + view.child(Banner::new().child( + Label::new( + "Shortcut changes are saved. Restart Chartr to rebuild the application keymap.", + ) + .size(LabelSize::Small), + )) + }) + .child( + Label::new( + "Click a shortcut, then press one key chord. Conflicts in the Chartr context are rejected.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(table) + .into_any_element() + } + + fn plugins_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let origin_available = self.original.upgrade().is_some(); + let (descriptors, rejected) = self + .original + .upgrade() + .map(|origin| origin.read(cx).settings_plugins()) + .unwrap_or_default(); + let settings = self.settings(cx); + let rows: Vec<_> = descriptors + .into_iter() + .map(|descriptor| { + let manifest = descriptor.manifest; + let enabled = descriptor.enabled; + let has_settings = descriptor.has_settings; + let id = manifest.id.clone(); + let control_id = id.clone(); + let configured = settings.plugin(&id); + let toggle = cx.listener(move |this, _, _, cx| { + this.set_plugin_enabled(id.clone(), !enabled, cx) + }); + let trust = match manifest.kind { + zeddy_plugin::manifest::Kind::Native => { + "Native — fully trusted code".to_owned() + } + zeddy_plugin::manifest::Kind::Web => { + let project = match manifest.permissions.project_files { + zeddy_plugin::manifest::ProjectAccess::None => "no project files", + zeddy_plugin::manifest::ProjectAccess::Read => "read project files", + zeddy_plugin::manifest::ProjectAccess::ReadWrite => { + "read/write project files" + } + }; + let mut grants = vec![project.to_owned()]; + if !manifest.permissions.network.is_empty() { + grants.push(format!( + "network: {}", + manifest.permissions.network.join(", ") + )); + } + if manifest.permissions.process { + grants.push("process actions".to_owned()); + } + if manifest.permissions.session { + grants.push("bound-session actions".to_owned()); + } + format!("Web — {}", grants.join(" · ")) + } + }; + let unsafe_control = + (manifest.kind == zeddy_plugin::manifest::Kind::Web).then(|| { + let id = manifest.id.clone(); + let change = cx.listener(move |this, _, _, cx| { + this.set_plugin_unsafe(id.clone(), !configured.unsafe_filesystem, cx) + }); + Button::new( + format!("plugin-unsafe-{}", manifest.id), + if configured.unsafe_filesystem { + "Unsafe filesystem granted" + } else { + "Grant unsafe filesystem" + }, + ) + .disabled(!origin_available) + .toggle_state(configured.unsafe_filesystem) + .on_click(change) + }); + let configure = has_settings.then(|| { + let id = manifest.id.clone(); + Button::new(format!("plugin-settings-{}", manifest.id), "Configure") + .disabled(!origin_available) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_plugin_settings(id.clone(), window, cx) + })) + }); + v_flex() + .gap_2() + .p_3() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + h_flex() + .justify_between() + .child( + v_flex() + .child(Label::new(manifest.name).size(LabelSize::Small)) + .child( + Label::new(manifest.id) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new( + format!("plugin-enabled-{control_id}"), + if enabled { "Enabled" } else { "Disabled" }, + ) + .disabled(!origin_available) + .toggle_state(enabled) + .on_click(toggle), + ), + ) + .child(Label::new(trust).size(LabelSize::XSmall).color(Color::Muted)) + .when_some(configure, |row, control| row.child(control)) + .when_some(unsafe_control, |row, control| row.child(control)) + }) + .collect(); + let rejected: Vec<_> = rejected + .into_iter() + .map(|rejected| { + Banner::new().severity(Severity::Error).child( + Label::new(format!("{}: {}", rejected.dir.display(), rejected.why)) + .size(LabelSize::XSmall), + ) + }) + .collect(); + let _ = window; + v_flex() + .gap_2() + .when(!origin_available, |view| { + view.child( + Banner::new().child( + Label::new( + "Open Settings from a Chartr workspace to manage runtime plugins.", + ) + .size(LabelSize::Small), + ), + ) + }) + .when(rows.is_empty() && rejected.is_empty(), |view| { + view.child(Label::new("No plugins installed.").color(Color::Muted)) + }) + .children(rows) + .children(rejected) + .into_any_element() + } +} + +impl Focusable for SettingsWindow { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for SettingsWindow { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let selected = self.page; + let navigation: Vec<_> = SettingsPage::ALL + .into_iter() + .map(|page| { + div() + .id(format!("settings-page-{}", page.slug())) + .role(Role::Tab) + .aria_label(page.title()) + .aria_selected(page == selected) + .mx_1() + .px_2() + .py_1() + .rounded_sm() + .cursor_pointer() + .when(page == selected, |row| { + row.bg(cx.theme().colors().element_selected) + .text_color(cx.theme().colors().text) + }) + .when(page != selected, |row| { + row.text_color(cx.theme().colors().text_muted) + .hover(|row| row.bg(cx.theme().colors().element_hover)) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.page = page; + if page != SettingsPage::Plugins { + this.plugin_settings = None; + } + cx.notify(); + })) + .child(Label::new(page.title()).size(LabelSize::Small)) + }) + .collect(); + let unreadable = cx.global::().unreadable().map(str::to_owned); + let page_title = self.page.title(); + let content = self.content(window, cx); + + div() + .id("settings-window") + .key_context("ChartrSettings") + .track_focus(&self.focus) + .size_full() + .bg(cx.theme().colors().background) + .text_color(cx.theme().colors().text) + .on_action(cx.listener(|_, _: &Close, window, _| window.remove_window())) + .on_action(cx.listener(|_, _: &crate::actions::settings::Open, window, _| { + window.activate_window() + })) + .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) + .child( + h_flex() + .size_full() + .min_h_0() + .child( + v_flex() + .w(px(176.)) + .h_full() + .py_3() + .border_r_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().surface_background) + .child( + div().px_3().pb_2().child( + Label::new("Settings") + .size(LabelSize::Large) + .weight(FontWeight::SEMIBOLD), + ), + ) + .child(div().px_3().py_1().child( + Label::new("Options").size(LabelSize::XSmall).color(Color::Muted), + )) + .children(navigation), + ) + .child( + div() + .id("settings-content-scroll") + .flex_1() + .h_full() + .min_w_0() + .overflow_y_scroll() + .child( + v_flex() + .w_full() + .max_w(px(720.)) + .p_6() + .gap_4() + .child(Label::new(page_title).size(LabelSize::Large)) + .when_some(unreadable, |view, problem| { + view.child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(problem).size(LabelSize::Small)), + ) + }) + .when_some(self.problem.clone(), |view, problem| { + view.child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(problem).size(LabelSize::Small)), + ) + }) + .child(content), + ), + ), + ) + } +} + +fn setting_label(label: &'static str) -> AnyElement { + Label::new(label).size(LabelSize::Small).color(Color::Muted).into_any_element() +} + +fn setting_value(label: &'static str, value: String) -> AnyElement { + h_flex() + .justify_between() + .gap_4() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .child(Label::new(value).size(LabelSize::Small)) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + struct WorkspacePlaceholder; + + impl Render for WorkspacePlaceholder { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + let settings = SettingsStore::bare(); + theme::set_theme_settings_provider( + Box::new(Fonts::from_settings(settings.resolved())), + cx, + ); + cx.set_global(settings); + let keymap = KeymapStore::bare(); + init(&keymap, cx); + cx.set_global(keymap); + }); + } + + fn settings_window_count(cx: &TestAppContext) -> usize { + cx.windows().into_iter().filter_map(|window| window.downcast::()).count() + } + + #[gpui::test] + fn reopening_settings_reuses_the_application_window(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + assert_eq!(settings_window_count(cx), 1); + + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + assert_eq!(settings_window_count(cx), 1); + } + + #[gpui::test] + fn settings_closes_when_the_last_workspace_window_closes(cx: &mut TestAppContext) { + init_test(cx); + let workspace = cx.add_window(|_, _| WorkspacePlaceholder); + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + assert_eq!(settings_window_count(cx), 1); + + cx.update(|cx| { + workspace.update(cx, |_, window, _| window.remove_window()).unwrap(); + }); + cx.run_until_parked(); + assert_eq!(settings_window_count(cx), 0); + } + + #[gpui::test] + fn platform_close_shortcut_closes_only_the_settings_window(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + let settings = cx + .windows() + .into_iter() + .find(|window| window.downcast::().is_some()) + .unwrap(); + let mut window = gpui::VisualTestContext::from_window(settings, cx); + #[cfg(target_os = "macos")] + window.simulate_keystrokes("cmd-w"); + #[cfg(not(target_os = "macos"))] + window.simulate_keystrokes("ctrl-w"); + window.run_until_parked(); + assert_eq!(settings_window_count(cx), 0); + } + + #[gpui::test] + fn edits_use_the_application_global_settings_store(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + let settings = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap(); + cx.update(|cx| { + settings + .update(cx, |settings, _, cx| settings.set_terminate_on_exit(true, cx)) + .unwrap(); + assert!(cx.global::().resolved().terminate_sessions_on_exit); + }); + } +} diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 2851ab34..4ae99421 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -583,8 +583,7 @@ impl Space { | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } - | Action::ToggleMode - | Action::ToggleSidebarScope => {} + | Action::OpenSettings => {} } cx.notify(); } @@ -943,7 +942,7 @@ impl Drop for Space { pub fn name_for(kind: Kind, path: &std::path::Path) -> String { match kind { - Kind::AdHoc => "Ad-hoc sessions".to_owned(), + Kind::AdHoc => "Free sessions".to_owned(), Kind::Registered => spaces::display_name(path), } } @@ -953,7 +952,7 @@ mod tests { use super::*; #[test] - fn the_synthetic_space_has_the_product_name_from_the_sketch() { - assert_eq!(name_for(Kind::AdHoc, std::path::Path::new("/home/op")), "Ad-hoc sessions"); + fn the_folderless_space_is_named_free_sessions() { + assert_eq!(name_for(Kind::AdHoc, std::path::Path::new("/home/op")), "Free sessions"); } } diff --git a/crates/zeddy/src/text_input.rs b/crates/zeddy/src/text_input.rs new file mode 100644 index 00000000..6c147ad8 --- /dev/null +++ b/crates/zeddy/src/text_input.rs @@ -0,0 +1,1053 @@ +//! A native-behaving, single-line GPUI text input. +//! +//! This follows GPUI's canonical `examples/input.rs` architecture: the model +//! implements [`EntityInputHandler`], so text composition, dead keys, input +//! methods, and accessibility cross the platform text-input bridge instead of +//! being reconstructed from key-down events. The bindings below mirror the +//! single-line subset of Zed's platform editor keymaps. + +use std::ops::Range; + +use gpui::{ + App, Bounds, ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, + Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, KeyBinding, + LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, + ShapedLine, SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window, actions, + fill, point, prelude::*, px, relative, size, +}; +use ui::prelude::*; +use unicode_segmentation::UnicodeSegmentation as _; + +actions!( + native_text_input, + [ + Backspace, + Delete, + DeleteWordBackward, + DeleteWordForward, + DeleteToBeginning, + DeleteToEnd, + Left, + Right, + WordLeft, + WordRight, + SelectLeft, + SelectRight, + SelectWordLeft, + SelectWordRight, + Home, + End, + SelectHome, + SelectEnd, + SelectAll, + Paste, + Cut, + Copy, + Undo, + Redo, + ShowCharacterPalette, + ] +); + +/// Register the native platform bindings in this input's narrow key context. +pub fn init(cx: &mut App) { + let context = Some("NativeTextInput"); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, context), + KeyBinding::new("shift-backspace", Backspace, context), + KeyBinding::new("delete", Delete, context), + KeyBinding::new("left", Left, context), + KeyBinding::new("right", Right, context), + KeyBinding::new("shift-left", SelectLeft, context), + KeyBinding::new("shift-right", SelectRight, context), + KeyBinding::new("home", Home, context), + KeyBinding::new("end", End, context), + KeyBinding::new("shift-home", SelectHome, context), + KeyBinding::new("shift-end", SelectEnd, context), + ]); + + #[cfg(target_os = "macos")] + cx.bind_keys([ + KeyBinding::new("cmd-a", SelectAll, context), + KeyBinding::new("cmd-c", Copy, context), + KeyBinding::new("cmd-x", Cut, context), + KeyBinding::new("cmd-v", Paste, context), + KeyBinding::new("cmd-z", Undo, context), + KeyBinding::new("cmd-shift-z", Redo, context), + KeyBinding::new("alt-left", WordLeft, context), + KeyBinding::new("alt-right", WordRight, context), + KeyBinding::new("alt-shift-left", SelectWordLeft, context), + KeyBinding::new("alt-shift-right", SelectWordRight, context), + KeyBinding::new("alt-backspace", DeleteWordBackward, context), + KeyBinding::new("alt-delete", DeleteWordForward, context), + KeyBinding::new("cmd-left", Home, context), + KeyBinding::new("cmd-right", End, context), + KeyBinding::new("cmd-up", Home, context), + KeyBinding::new("cmd-down", End, context), + KeyBinding::new("cmd-shift-left", SelectHome, context), + KeyBinding::new("cmd-shift-right", SelectEnd, context), + KeyBinding::new("cmd-shift-up", SelectHome, context), + KeyBinding::new("cmd-shift-down", SelectEnd, context), + KeyBinding::new("cmd-backspace", DeleteToBeginning, context), + KeyBinding::new("cmd-delete", DeleteToEnd, context), + KeyBinding::new("ctrl-a", Home, context), + KeyBinding::new("ctrl-e", End, context), + KeyBinding::new("ctrl-b", Left, context), + KeyBinding::new("ctrl-f", Right, context), + KeyBinding::new("ctrl-h", Backspace, context), + KeyBinding::new("ctrl-d", Delete, context), + KeyBinding::new("ctrl-w", DeleteWordBackward, context), + KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, context), + ]); + + #[cfg(not(target_os = "macos"))] + cx.bind_keys([ + KeyBinding::new("ctrl-a", SelectAll, context), + KeyBinding::new("ctrl-c", Copy, context), + KeyBinding::new("ctrl-x", Cut, context), + KeyBinding::new("ctrl-v", Paste, context), + KeyBinding::new("cut", Cut, context), + KeyBinding::new("copy", Copy, context), + KeyBinding::new("paste", Paste, context), + KeyBinding::new("ctrl-insert", Copy, context), + KeyBinding::new("shift-delete", Cut, context), + KeyBinding::new("shift-insert", Paste, context), + KeyBinding::new("ctrl-z", Undo, context), + KeyBinding::new("ctrl-y", Redo, context), + KeyBinding::new("ctrl-shift-z", Redo, context), + KeyBinding::new("undo", Undo, context), + KeyBinding::new("redo", Redo, context), + KeyBinding::new("ctrl-left", WordLeft, context), + KeyBinding::new("ctrl-right", WordRight, context), + KeyBinding::new("ctrl-shift-left", SelectWordLeft, context), + KeyBinding::new("ctrl-shift-right", SelectWordRight, context), + KeyBinding::new("ctrl-backspace", DeleteWordBackward, context), + KeyBinding::new("ctrl-delete", DeleteWordForward, context), + KeyBinding::new("ctrl-home", Home, context), + KeyBinding::new("ctrl-end", End, context), + KeyBinding::new("ctrl-shift-home", SelectHome, context), + KeyBinding::new("ctrl-shift-end", SelectEnd, context), + KeyBinding::new("ctrl-alt-space", ShowCharacterPalette, context), + ]); +} + +#[derive(Debug, Clone, Copy)] +pub enum InputEvent { + Edited, +} + +impl EventEmitter for TextInput {} + +#[derive(Clone)] +struct Snapshot { + content: SharedString, + selected_range: Range, + selection_reversed: bool, +} + +/// A reusable single-line input model and view. +pub struct TextInput { + focus_handle: FocusHandle, + content: SharedString, + placeholder: SharedString, + selected_range: Range, + selection_reversed: bool, + marked_range: Option>, + last_layout: Option, + last_bounds: Option>, + scroll_x: Pixels, + is_selecting: bool, + undo: Vec, + redo: Vec, +} + +impl TextInput { + pub fn new(placeholder: impl Into, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + content: "".into(), + placeholder: placeholder.into(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + last_layout: None, + last_bounds: None, + scroll_x: px(0.), + is_selecting: false, + undo: Vec::new(), + redo: Vec::new(), + } + } + + pub fn text(&self) -> &str { + &self.content + } + + pub fn set_text( + &mut self, + text: impl Into, + select_all: bool, + cx: &mut Context, + ) { + self.content = text.into(); + self.marked_range = None; + self.undo.clear(); + self.redo.clear(); + self.scroll_x = px(0.); + if select_all { + self.selected_range = 0..self.content.len(); + } else { + self.selected_range = self.content.len()..self.content.len(); + } + self.selection_reversed = false; + cx.emit(InputEvent::Edited); + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut Context) { + self.set_text("", false, cx); + } + + fn snapshot(&self) -> Snapshot { + Snapshot { + content: self.content.clone(), + selected_range: self.selected_range.clone(), + selection_reversed: self.selection_reversed, + } + } + + fn restore(&mut self, snapshot: Snapshot, cx: &mut Context) { + self.content = snapshot.content; + self.selected_range = snapshot.selected_range; + self.selection_reversed = snapshot.selection_reversed; + self.marked_range = None; + cx.emit(InputEvent::Edited); + cx.notify(); + } + + fn cursor_offset(&self) -> usize { + if self.selection_reversed { self.selected_range.start } else { self.selected_range.end } + } + + fn anchor_offset(&self) -> usize { + if self.selection_reversed { self.selected_range.end } else { self.selected_range.start } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + cx.notify(); + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + let anchor = self.anchor_offset(); + let head = offset.min(self.content.len()); + self.selected_range = anchor.min(head)..anchor.max(head); + self.selection_reversed = head < anchor; + cx.notify(); + } + + fn previous_grapheme(&self, offset: usize) -> usize { + self.content + .grapheme_indices(true) + .rev() + .find_map(|(index, _)| (index < offset).then_some(index)) + .unwrap_or(0) + } + + fn next_grapheme(&self, offset: usize) -> usize { + self.content + .grapheme_indices(true) + .find_map(|(index, _)| (index > offset).then_some(index)) + .unwrap_or(self.content.len()) + } + + fn previous_word_start(&self, offset: usize) -> usize { + self.content[..offset] + .unicode_word_indices() + .map(|(index, _)| index) + .next_back() + .unwrap_or(0) + } + + fn next_word_end(&self, offset: usize) -> usize { + self.content[offset..] + .unicode_word_indices() + .next() + .map(|(index, word)| offset + index + word.len()) + .unwrap_or(self.content.len()) + } + + fn word_range_at(&self, offset: usize) -> Range { + if self.content.is_empty() { + return 0..0; + } + let offset = offset.min(self.content.len().saturating_sub(1)); + self.content + .split_word_bound_indices() + .find_map(|(start, segment)| { + let end = start + segment.len(); + (start <= offset && offset < end).then_some(start..end) + }) + .unwrap_or(offset..self.next_grapheme(offset)) + } + + fn replace_range(&mut self, range: Range, new_text: &str, cx: &mut Context) { + let new_text = new_text.replace(['\r', '\n'], " "); + if range.is_empty() && new_text.is_empty() { + return; + } + self.undo.push(self.snapshot()); + self.redo.clear(); + self.content = + format!("{}{}{}", &self.content[..range.start], new_text, &self.content[range.end..]) + .into(); + let cursor = range.start + new_text.len(); + self.selected_range = cursor..cursor; + self.selection_reversed = false; + self.marked_range = None; + cx.emit(InputEvent::Edited); + cx.notify(); + } + + fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let target = if self.selected_range.is_empty() { + self.previous_grapheme(self.cursor_offset()) + } else { + self.selected_range.start + }; + self.move_to(target, cx); + } + + fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let target = if self.selected_range.is_empty() { + self.next_grapheme(self.cursor_offset()) + } else { + self.selected_range.end + }; + self.move_to(target, cx); + } + + fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + let target = if self.selected_range.is_empty() { + self.previous_word_start(self.cursor_offset()) + } else { + self.selected_range.start + }; + self.move_to(target, cx); + } + + fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + let target = if self.selected_range.is_empty() { + self.next_word_end(self.cursor_offset()) + } else { + self.selected_range.end + }; + self.move_to(target, cx); + } + + fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_grapheme(self.cursor_offset()), cx); + } + + fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_grapheme(self.cursor_offset()), cx); + } + + fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_word_start(self.cursor_offset()), cx); + } + + fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_word_end(self.cursor_offset()), cx); + } + + fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.move_to(0, cx); + } + + fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context) { + self.select_to(0, cx); + } + + fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context) { + self.select_to(self.content.len(), cx); + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + + fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + let range = if self.selected_range.is_empty() { + let cursor = self.cursor_offset(); + self.previous_grapheme(cursor)..cursor + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + let range = if self.selected_range.is_empty() { + let cursor = self.cursor_offset(); + cursor..self.next_grapheme(cursor) + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn delete_word_backward( + &mut self, + _: &DeleteWordBackward, + window: &mut Window, + cx: &mut Context, + ) { + let range = if self.selected_range.is_empty() { + let cursor = self.cursor_offset(); + self.previous_word_start(cursor)..cursor + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn delete_word_forward( + &mut self, + _: &DeleteWordForward, + window: &mut Window, + cx: &mut Context, + ) { + let range = if self.selected_range.is_empty() { + let cursor = self.cursor_offset(); + cursor..self.next_word_end(cursor) + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn delete_to_beginning( + &mut self, + _: &DeleteToBeginning, + window: &mut Window, + cx: &mut Context, + ) { + let range = if self.selected_range.is_empty() { + 0..self.cursor_offset() + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn delete_to_end(&mut self, _: &DeleteToEnd, window: &mut Window, cx: &mut Context) { + let range = if self.selected_range.is_empty() { + self.cursor_offset()..self.content.len() + } else { + self.selected_range.clone() + }; + if range.is_empty() { + window.play_system_bell(); + } else { + self.replace_range(range, "", cx); + } + } + + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_owned(), + )); + } + } + + fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_owned(), + )); + self.replace_range(self.selected_range.clone(), "", cx); + } + } + + fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + self.replace_range(self.selected_range.clone(), &text, cx); + } + } + + fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if let Some(previous) = self.undo.pop() { + self.redo.push(self.snapshot()); + self.restore(previous, cx); + } + } + + fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if let Some(next) = self.redo.pop() { + self.undo.push(self.snapshot()); + self.restore(next, cx); + } + } + + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + _: &mut Context, + ) { + window.show_character_palette(); + } + + fn on_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus_handle, cx); + self.is_selecting = true; + let index = self.index_for_mouse_position(event.position); + match event.click_count { + 1 if event.modifiers.shift => self.select_to(index, cx), + 1 => self.move_to(index, cx), + 2 => { + self.selected_range = self.word_range_at(index); + self.selection_reversed = false; + cx.notify(); + } + _ => { + self.selected_range = 0..self.content.len(); + self.selection_reversed = false; + cx.notify(); + } + } + } + + fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { + self.is_selecting = false; + } + + fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { + if self.is_selecting { + self.select_to(self.index_for_mouse_position(event.position), cx); + } + } + + fn index_for_mouse_position(&self, position: Point) -> usize { + if self.content.is_empty() { + return 0; + } + let (Some(bounds), Some(line)) = (self.last_bounds, self.last_layout.as_ref()) else { + return 0; + }; + if position.x <= bounds.left() { + return 0; + } + if position.x >= bounds.right() { + return self.content.len(); + } + line.closest_index_for_x(position.x - bounds.left() + self.scroll_x) + } + + fn offset_from_utf16(&self, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in self.content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset + } + + fn offset_to_utf16(&self, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + for ch in self.content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + utf16_offset + } + + fn range_to_utf16(&self, range: &Range) -> Range { + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) + } + + fn range_from_utf16(&self, range: &Range) -> Range { + self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end) + } + + fn offset_from_utf16_in(text: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in text.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset + } +} + +impl EntityInputHandler for TextInput { + fn text_for_range( + &mut self, + range: Range, + actual_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range); + actual_range.replace(self.range_to_utf16(&range)); + Some(self.content[range].to_owned()) + } + + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selected_range), + reversed: self.selection_reversed, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + self.marked_range.as_ref().map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + _: &mut Window, + cx: &mut Context, + ) { + let range = range + .as_ref() + .map(|range| self.range_from_utf16(range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + self.replace_range(range, text, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + text: &str, + selected: Option>, + _: &mut Window, + cx: &mut Context, + ) { + let range = range + .as_ref() + .map(|range| self.range_from_utf16(range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + let snapshot = self.snapshot(); + self.undo.push(snapshot); + self.redo.clear(); + let text = text.replace(['\r', '\n'], " "); + self.content = + format!("{}{}{}", &self.content[..range.start], text, &self.content[range.end..]) + .into(); + self.marked_range = (!text.is_empty()).then_some(range.start..range.start + text.len()); + self.selected_range = selected + .as_ref() + .map(|selection| { + Self::offset_from_utf16_in(&text, selection.start) + ..Self::offset_from_utf16_in(&text, selection.end) + }) + .map(|selection| range.start + selection.start..range.start + selection.end) + .unwrap_or_else(|| range.start + text.len()..range.start + text.len()); + self.selection_reversed = false; + cx.emit(InputEvent::Edited); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range: Range, + bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + let line = self.last_layout.as_ref()?; + let range = self.range_from_utf16(&range); + Some(Bounds::from_corners( + point(bounds.left() + line.x_for_index(range.start) - self.scroll_x, bounds.top()), + point(bounds.left() + line.x_for_index(range.end) - self.scroll_x, bounds.bottom()), + )) + } + + fn character_index_for_point( + &mut self, + point: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + let bounds = self.last_bounds?; + let line = self.last_layout.as_ref()?; + let index = line.index_for_x(point.x - bounds.left() + self.scroll_x)?; + Some(self.offset_to_utf16(index)) + } +} + +struct TextElement { + input: Entity, +} + +struct PrepaintState { + line: Option, + cursor: Option, + selection: Option, + scroll_x: Pixels, +} + +impl IntoElement for TextElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TextElement { + type RequestLayoutState = (); + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let style = Style { + size: size(relative(1.).into(), window.line_height().into()), + ..Style::default() + }; + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) -> PrepaintState { + let style = window.text_style(); + let colors = cx.theme().colors(); + let (display_text, text_color, selected_range, cursor, marked_range, previous_scroll) = { + let input = self.input.read(cx); + let display = if input.content.is_empty() { + (input.placeholder.clone(), colors.text_muted) + } else { + (input.content.clone(), style.color) + }; + ( + display.0, + display.1, + input.selected_range.clone(), + input.cursor_offset(), + input.marked_range.clone(), + input.scroll_x, + ) + }; + + let run = TextRun { + len: display_text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + let runs = if let Some(marked) = marked_range { + vec![ + TextRun { len: marked.start, ..run.clone() }, + TextRun { + len: marked.end - marked.start, + underline: Some(UnderlineStyle { + color: Some(run.color), + thickness: px(1.), + wavy: false, + }), + ..run.clone() + }, + TextRun { len: display_text.len() - marked.end, ..run }, + ] + .into_iter() + .filter(|run| run.len > 0) + .collect() + } else { + vec![run] + }; + let font_size = style.font_size.to_pixels(window.rem_size()); + let line = window.text_system().shape_line(display_text, font_size, &runs, None); + let cursor_x = line.x_for_index(cursor); + let viewport = bounds.size.width.max(px(1.)); + let max_scroll = (line.width - viewport).max(px(0.)); + let mut scroll_x = previous_scroll.min(max_scroll); + if cursor_x < scroll_x { + scroll_x = cursor_x; + } else if cursor_x > scroll_x + viewport - px(2.) { + scroll_x = (cursor_x - viewport + px(2.)).min(max_scroll); + } + + let (selection, cursor) = if selected_range.is_empty() { + ( + None, + Some(fill( + Bounds::new( + point(bounds.left() + cursor_x - scroll_x, bounds.top()), + size(px(1.), bounds.size.height), + ), + cx.theme().players().local().cursor, + )), + ) + } else { + ( + Some(fill( + Bounds::from_corners( + point( + bounds.left() + line.x_for_index(selected_range.start) - scroll_x, + bounds.top(), + ), + point( + bounds.left() + line.x_for_index(selected_range.end) - scroll_x, + bounds.bottom(), + ), + ), + colors.element_selection_background, + )), + None, + ) + }; + PrepaintState { line: Some(line), cursor, selection, scroll_x } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut (), + state: &mut PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus = self.input.read(cx).focus_handle.clone(); + window.handle_input(&focus, ElementInputHandler::new(bounds, self.input.clone()), cx); + if let Some(selection) = state.selection.take() { + window.paint_quad(selection); + } + let line = state.line.take().expect("prepaint shaped the input line"); + let _ = line.paint( + point(bounds.left() - state.scroll_x, bounds.top()), + window.line_height(), + gpui::TextAlign::Left, + None, + window, + cx, + ); + if focus.is_focused(window) + && let Some(cursor) = state.cursor.take() + { + window.paint_quad(cursor); + } + self.input.update(cx, |input, _| { + input.last_layout = Some(line); + input.last_bounds = Some(bounds); + input.scroll_x = state.scroll_x; + }); + } +} + +impl Render for TextInput { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("native-text-input") + .key_context("NativeTextInput") + .role(gpui::Role::TextInput) + .aria_label(self.placeholder.clone()) + .track_focus(&self.focus_handle(cx)) + .cursor(CursorStyle::IBeam) + .w_full() + .min_w_0() + .overflow_hidden() + .on_action(cx.listener(Self::backspace)) + .on_action(cx.listener(Self::delete)) + .on_action(cx.listener(Self::delete_word_backward)) + .on_action(cx.listener(Self::delete_word_forward)) + .on_action(cx.listener(Self::delete_to_beginning)) + .on_action(cx.listener(Self::delete_to_end)) + .on_action(cx.listener(Self::left)) + .on_action(cx.listener(Self::right)) + .on_action(cx.listener(Self::word_left)) + .on_action(cx.listener(Self::word_right)) + .on_action(cx.listener(Self::select_left)) + .on_action(cx.listener(Self::select_right)) + .on_action(cx.listener(Self::select_word_left)) + .on_action(cx.listener(Self::select_word_right)) + .on_action(cx.listener(Self::home)) + .on_action(cx.listener(Self::end)) + .on_action(cx.listener(Self::select_home)) + .on_action(cx.listener(Self::select_end)) + .on_action(cx.listener(Self::select_all)) + .on_action(cx.listener(Self::paste)) + .on_action(cx.listener(Self::cut)) + .on_action(cx.listener(Self::copy)) + .on_action(cx.listener(Self::undo)) + .on_action(cx.listener(Self::redo)) + .on_action(cx.listener(Self::show_character_palette)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_mouse_move(cx.listener(Self::on_mouse_move)) + .child(TextElement { input: cx.entity() }) + } +} + +impl Focusable for TextInput { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + struct Harness { + input: Entity, + } + + impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.input.clone()) + } + } + + fn setup(cx: &mut TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + init(cx); + }); + let (harness, cx) = cx.add_window_view(|_, cx| Harness { + input: cx.new(|cx| TextInput::new("Type here…", cx)), + }); + let input = cx.read_entity(&harness, |harness, _| harness.input.clone()); + cx.update(|window, cx| window.focus(&input.focus_handle(cx), cx)); + (input, cx) + } + + fn platform(shortcut: &'static str) -> &'static str { + #[cfg(target_os = "macos")] + return match shortcut { + "select_all" => "cmd-a", + "copy" => "cmd-c", + "paste" => "cmd-v", + "undo" => "cmd-z", + "word_left" => "alt-left", + _ => unreachable!(), + }; + + #[cfg(not(target_os = "macos"))] + return match shortcut { + "select_all" => "ctrl-a", + "copy" => "ctrl-c", + "paste" => "ctrl-v", + "undo" => "ctrl-z", + "word_left" => "ctrl-left", + _ => unreachable!(), + }; + } + + #[gpui::test] + fn select_all_and_clipboard_use_platform_shortcuts(cx: &mut TestAppContext) { + let (input, cx) = setup(cx); + cx.simulate_input("copy me"); + cx.simulate_keystrokes(platform("select_all")); + cx.read_entity(&input, |input, _| assert_eq!(input.selected_range, 0..7)); + cx.simulate_keystrokes(platform("copy")); + + input.update(cx, |input, cx| input.clear(cx)); + cx.simulate_keystrokes(platform("paste")); + cx.read_entity(&input, |input, _| assert_eq!(input.text(), "copy me")); + + cx.simulate_keystrokes(platform("select_all")); + cx.simulate_input("replaced"); + cx.read_entity(&input, |input, _| assert_eq!(input.text(), "replaced")); + } + + #[gpui::test] + fn word_motion_grapheme_deletion_and_undo_match_native_edits(cx: &mut TestAppContext) { + let (input, cx) = setup(cx); + cx.simulate_input("alpha beta"); + cx.simulate_keystrokes(platform("word_left")); + cx.read_entity(&input, |input, _| assert_eq!(input.cursor_offset(), 6)); + + input.update(cx, |input, cx| input.set_text("a👨‍👩‍👧‍👦", false, cx)); + cx.simulate_keystrokes("backspace"); + cx.read_entity(&input, |input, _| assert_eq!(input.text(), "a")); + cx.simulate_keystrokes(platform("undo")); + cx.read_entity(&input, |input, _| assert_eq!(input.text(), "a👨‍👩‍👧‍👦")); + } +} diff --git a/docs/acceptance.md b/docs/acceptance.md index 0d5773e7..4528b620 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -23,7 +23,7 @@ replace the daemon, and reject the stale session identity. Review at 700×900, 1100×720, and a maximized window in both Chartr Dark and Chartr Light. Capture and compare: -- empty Ad-hoc startup, one folder, and several spaces; +- empty Free sessions startup, one folder, and several spaces; - Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; - empty space, one standalone tab, nested horizontal/vertical panes, resized dividers, zoom, and automatic split collapse after its last item moves or closes; @@ -37,8 +37,8 @@ Chartr Light. Capture and compare: - Zed-style transient pane-body drop highlights: full-content center and half-content left, right, top, and bottom targets, including nearest-edge corner resolution and no split target over a pane's tab bar; -- General, Appearance, Terminal, Hotkeys, Plugins, and a contributed plugin - Settings view; +- one native, application-wide Settings window with General, Appearance, + Terminal, Hotkeys, Plugins, and a contributed plugin Settings view; - command palette, unavailable-folder recovery, broken-stream recovery, backend crash-loop banner, rejected plugin, and visible web permissions; - the native Hello pane and real Clock web pane, including its persisted format. @@ -52,7 +52,8 @@ tier. Run the matrix with pointer and keyboard. Confirm `Cmd/Ctrl+W`, command palette, directional focus, split-and-move, move-to-existing-pane, join, zoom, Settings -close/focus restoration, and `Ctrl+Tab` Settings-page cycling. Every drag outcome +singleton focus, native `Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. +Close the last workspace and confirm Settings closes too. Every drag outcome must have a semantic action alternative. With two panes already open, invoke all four split directions from the first lone-tab pane and confirm each creates the expected adjacent empty drop target without moving, losing focus, or collapsing. @@ -84,8 +85,8 @@ alternate transition path. ## Persistence and lifecycle Relaunch after changing window bounds, sidebar width/scope, mode, space names, -outer-tab order, split ratios, active groups/panes/items, plugin Settings, and a missing folder. Confirm -normal exit adopts detached terminals; item close kills exactly one session; +outer-tab order, split ratios, active groups/panes/items, plugin Settings, and a +missing folder. Confirm normal exit adopts detached terminals; item close kills exactly one session; closing a populated pane or folder space confirms and kills all descendants; session-bound plugins cascade; disabling or revoking a plugin closes every live instance; and stale backend/plugin records are summarized without corrupting the diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md index 1541c644..4d02094c 100644 --- a/docs/adr/0005-spaces-follow-zed-multi-workspace.md +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -31,7 +31,7 @@ pane trees, item ownership, and restorable plugin state live in Chartr's SQLite state store. A pre-outer-tab pane tree migrates to one grouped outer entry. The rewrite deliberately does not import or mutate older Chartr registries. -Ad-hoc sessions are the one synthetic space. They use the operator's home +Free sessions are the one synthetic space. They use the operator's home directory and have no registry row. A registered home-directory row is not drawn beside it because herdr has one workspace per directory; two labels over one backend workspace would pretend to be independent state when they are not. From 5b2e4e9a04d8f7e9696729eae970b776451049b7 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 15:01:21 +0800 Subject: [PATCH 009/110] refactor: standardize interface typography --- crates/zeddy/src/app.rs | 29 ++++++------- crates/zeddy/src/chrome.rs | 9 ++-- crates/zeddy/src/chrome/sidebar.rs | 7 ++-- crates/zeddy/src/chrome/tabs.rs | 3 +- crates/zeddy/src/fonts.rs | 39 +++++++++++++----- crates/zeddy/src/settings_window.rs | 64 +++++++++++++++-------------- 6 files changed, 88 insertions(+), 63 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index d2350b81..2346ef0c 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -29,7 +29,7 @@ use zeddy_plugin_host::{Catalog, FileBroker, PaneSource, Paths, SettingsSource}; use crate::{ actions, chrome::{self, Action, DraggedItem, Entry, SpaceEntries, dragged_item_preview}, - fonts::Fonts, + fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, item::PluginItem, keys, mode::Mode, @@ -2015,13 +2015,13 @@ impl Zeddy { Backend::Ready => {} Backend::Starting => notices.push( Banner::new() - .child(Label::new("Starting the terminal backend…").size(LabelSize::Small)) + .child(Label::new("Starting the terminal backend…").size(UI_LABEL_DEFAULT)) .into_any_element(), ), Backend::Recovering(detail) => notices.push( Banner::new() .severity(Severity::Warning) - .child(Label::new(detail).size(LabelSize::Small)) + .child(Label::new(detail).size(UI_LABEL_DEFAULT)) .into_any_element(), ), Backend::Failed(detail) => { @@ -2032,7 +2032,7 @@ impl Zeddy { Banner::new() .severity(Severity::Error) .wrap_content(true) - .child(Label::new(detail).size(LabelSize::Small)) + .child(Label::new(detail).size(UI_LABEL_DEFAULT)) .action_slot( h_flex() .gap_1() @@ -2050,7 +2050,7 @@ impl Zeddy { notices.push( Banner::new() .severity(Severity::Warning) - .child(Label::new(problem).size(LabelSize::Small)) + .child(Label::new(problem).size(UI_LABEL_DEFAULT)) .into_any_element(), ); } @@ -2058,7 +2058,7 @@ impl Zeddy { notices.push( Banner::new() .severity(Severity::Warning) - .child(Label::new(problem).size(LabelSize::Small)) + .child(Label::new(problem).size(UI_LABEL_DEFAULT)) .into_any_element(), ); } @@ -2260,7 +2260,7 @@ impl Zeddy { div().absolute().left_2().right_2().bottom_2().child( Banner::new() .severity(Severity::Error) - .child(Label::new(detail).size(LabelSize::Small)) + .child(Label::new(detail).size(UI_LABEL_DEFAULT)) .when( matches!(ended, crate::session::Ended::Failed(_)), |banner| { @@ -2522,7 +2522,7 @@ impl Zeddy { close_item(Action::Close { space: None, item: close }, window, cx) }), ) - .child(Label::new(item.title()).size(LabelSize::Small).truncate()) + .child(Label::new(item.title()).size(UI_LABEL_DEFAULT).truncate()) .into_any_element(), ) }); @@ -2595,12 +2595,10 @@ impl Zeddy { h_flex() .w_full() .justify_between() - .child(Label::new(label).size(LabelSize::Small)) + .child(Label::new(label).size(UI_LABEL_DEFAULT)) .when(!shortcut.is_empty(), |row| { row.child( - Label::new(shortcut) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(shortcut).size(UI_LABEL_SMALL).color(Color::Muted), ) }), ) @@ -2716,7 +2714,7 @@ impl Zeddy { .bg(cx.theme().colors().elevated_surface_background) .shadow_lg() .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(Label::new("Rename Space").size(LabelSize::Large)) + .child(Label::new("Rename Space").size(UI_LABEL_LARGE)) .child( h_flex() .h(px(36.)) @@ -2751,6 +2749,7 @@ impl Focusable for Zeddy { impl Render for Zeddy { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = Fonts::setup_ui(window, cx); let bounds = match window.window_bounds() { gpui::WindowBounds::Windowed(bounds) | gpui::WindowBounds::Maximized(bounds) @@ -2818,6 +2817,8 @@ impl Render for Zeddy { "Chartr" }) .size_full() + .font(ui_font) + .text_size(UI_TEXT_DEFAULT) .bg(background) .text_color(text) .on_drag_move::(cx.listener( @@ -3122,7 +3123,7 @@ fn empty_pane_message(text: &str, cx: &App) -> impl IntoElement { .p_2() .items_center() .justify_center() - .child(Label::new(text.to_owned()).size(LabelSize::Small).color(Color::Muted)) + .child(Label::new(text.to_owned()).size(UI_LABEL_DEFAULT).color(Color::Muted)) .bg(cx.theme().colors().editor_background) } diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index b1dd1303..7ea71b62 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -10,7 +10,10 @@ pub mod tabs; use std::rc::Rc; -use crate::workspace::{ItemId, PaneId, WorkspaceTabId}; +use crate::{ + fonts::UI_LABEL_DEFAULT, + workspace::{ItemId, PaneId, WorkspaceTabId}, +}; use gpui::EntityId; use ui::{CommonAnimationExt, Tab, prelude::*}; use zeddy_herdr::control::SessionStatus; @@ -95,7 +98,7 @@ impl Render for DraggedItem { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Tab::new(("dragged-item", self.item.get() as usize)) .toggle_state(self.selected) - .child(Label::new(self.title.clone()).size(LabelSize::Small)) + .child(Label::new(self.title.clone()).size(UI_LABEL_DEFAULT)) } } @@ -126,7 +129,7 @@ impl Render for DraggedItemPreview { div().relative().left(self.source_offset.x).top(self.source_offset.y).child( Tab::new(("dragged-item-preview", self.dragged.item.get() as usize)) .toggle_state(self.dragged.selected) - .child(Label::new(self.dragged.title.clone()).size(LabelSize::Small)), + .child(Label::new(self.dragged.title.clone()).size(UI_LABEL_DEFAULT)), ) } } diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 0ed06e03..70a05c4f 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -13,6 +13,7 @@ use super::{ Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, status_indicator, }; +use crate::fonts::{UI_LABEL_DEFAULT, UI_LABEL_SMALL}; /// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a /// preference to persist, a drag handle to hit-test, and a minimum to enforce, @@ -45,7 +46,7 @@ pub fn render( .pt_2() .pb_1() .justify_between() - .child(Label::new(space.name.clone()).size(LabelSize::XSmall).color(Color::Muted)) + .child(Label::new(space.name.clone()).size(UI_LABEL_SMALL).color(Color::Muted)) .child( h_flex() .gap_px() @@ -243,12 +244,12 @@ fn row( v_flex() .flex_1() .overflow_hidden() - .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()), + .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()), ) .when(grouped, |row| { row.child( Label::new(format!("{} tabs", entry.item_count)) - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ) }) diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 255d05d7..ad31f5ec 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -11,6 +11,7 @@ use ui::{ButtonSize, IconButtonShape, Tab, TabPosition, Tooltip, prelude::*}; use super::Emit; use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; +use crate::fonts::UI_LABEL_DEFAULT; pub fn render( entries: &[Entry], @@ -161,5 +162,5 @@ fn tab( cx, )) .end_slot::(close_slot) - .child(Label::new(entry.title.clone()).size(LabelSize::Small).truncate()) + .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()) } diff --git a/crates/zeddy/src/fonts.rs b/crates/zeddy/src/fonts.rs index 7ef0e93c..390d3572 100644 --- a/crates/zeddy/src/fonts.rs +++ b/crates/zeddy/src/fonts.rs @@ -8,8 +8,9 @@ use std::borrow::Cow; -use gpui::{App, Font, Pixels, px}; +use gpui::{App, Font, Pixels, Rems, Window, px}; use theme::{ThemeSettingsProvider, UiDensity}; +use ui::LabelSize; use crate::settings::ResolvedSettings; @@ -21,11 +22,21 @@ pub struct Fonts { buffer_size: Pixels, } -/// Chartr's typography defaults. IBM Plex Sans comes from Zed's asset bundle; -/// Mono is bundled below because Zed does not ship that face. -const UI_FAMILY: &str = "IBM Plex Sans"; +/// IBM Plex Sans comes from Zed's asset bundle; Mono is bundled below because +/// Zed does not ship that face. const MONOSPACE_FAMILY: &str = "IBM Plex Mono"; +/// Chartr's semantic interface type scale. These values are relative to the +/// configured `ui_font_size`, whose default is 14 px, so the default scale is +/// exactly 14/12/10 px while still respecting the user's interface scale. +pub const UI_TEXT_LARGE: Rems = Rems(1.); +pub const UI_TEXT_DEFAULT: Rems = Rems(12. / 14.); +pub const UI_TEXT_SMALL: Rems = Rems(10. / 14.); + +pub const UI_LABEL_LARGE: LabelSize = LabelSize::Custom(UI_TEXT_LARGE); +pub const UI_LABEL_DEFAULT: LabelSize = LabelSize::Custom(UI_TEXT_DEFAULT); +pub const UI_LABEL_SMALL: LabelSize = LabelSize::Custom(UI_TEXT_SMALL); + const IBM_PLEX_MONO: &[u8] = include_bytes!("../assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf"); @@ -35,12 +46,7 @@ pub fn load_bundled(cx: &App) -> anyhow::Result<()> { impl Default for Fonts { fn default() -> Self { - Self { - ui: gpui::font(UI_FAMILY), - buffer: gpui::font(MONOSPACE_FAMILY), - ui_size: px(14.), - buffer_size: px(13.), - } + Self::from_settings(&ResolvedSettings::default()) } } @@ -63,6 +69,16 @@ impl Fonts { let size = self.buffer_size; (self.buffer.clone(), size, (size * 1.4).round()) } + + /// Install the configured interface type scale on a window and return the + /// font its root should inherit. This is the same boundary as Zed's + /// `setup_ui_font`: `ui_font_size` is the root rem, so every UI component + /// and semantic `LabelSize` resolves from one user-controlled scale. + pub fn setup_ui(window: &mut Window, cx: &App) -> Font { + let settings = theme::theme_settings(cx); + window.set_rem_size(settings.ui_font_size(cx)); + settings.ui_font(cx).clone() + } } /// Whether the platform can actually rasterise the bundled terminal face. @@ -105,7 +121,8 @@ mod tests { #[test] fn zeddy_names_a_family_on_every_platform() { - assert!(!MONOSPACE_FAMILY.is_empty() && !UI_FAMILY.is_empty()); + let defaults = ResolvedSettings::default(); + assert!(!MONOSPACE_FAMILY.is_empty() && !defaults.ui_font_family.is_empty()); } #[test] diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index ef6a895c..83fe572b 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -18,7 +18,7 @@ use ui::{ use crate::{ app::Zeddy, - fonts::Fonts, + fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, keymap::{KeymapAction, KeymapStore}, mode::Mode, persistence::SidebarScope, @@ -502,7 +502,7 @@ impl SettingsWindow { return v_flex() .gap_3() .child(Button::new("plugin-settings-back", "Back to plugins").on_click(back)) - .child(Label::new(plugin.clone()).size(LabelSize::XSmall).color(Color::Muted)) + .child(Label::new(plugin.clone()).size(UI_LABEL_SMALL).color(Color::Muted)) .child(div().min_h(px(320.)).child(view.clone())) .into_any_element(); } @@ -531,13 +531,13 @@ impl SettingsWindow { cx.listener(|this, _, _, cx| this.set_sidebar_scope(SidebarScope::ActiveSpace, cx)); v_flex() .gap_4() - .child(Label::new("Chartr").size(LabelSize::Large)) + .child(Label::new("Chartr").size(UI_LABEL_LARGE)) .child( Label::new(format!( "Version {} · configuration namespace chartr-zeddy", env!("CARGO_PKG_VERSION") )) - .size(LabelSize::Small) + .size(UI_LABEL_SMALL) .color(Color::Muted), ) .child( @@ -546,10 +546,10 @@ impl SettingsWindow { .gap_4() .child( v_flex() - .child(Label::new("Terminate sessions on exit").size(LabelSize::Small)) + .child(Label::new("Terminate sessions on exit").size(UI_LABEL_DEFAULT)) .child( Label::new("Normal app exit detaches and leaves sessions running.") - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ), ) @@ -568,9 +568,9 @@ impl SettingsWindow { .justify_between() .gap_4() .child( - v_flex().child(Label::new("Session list").size(LabelSize::Small)).child( + v_flex().child(Label::new("Session list").size(UI_LABEL_DEFAULT)).child( Label::new("Show sessions in a sidebar or a tab strip.") - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ), ) @@ -597,9 +597,9 @@ impl SettingsWindow { .justify_between() .gap_4() .child( - v_flex().child(Label::new("Spaces shown").size(LabelSize::Small)).child( + v_flex().child(Label::new("Spaces shown").size(UI_LABEL_DEFAULT)).child( Label::new("Show every space or only the currently active space.") - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ), ) @@ -799,7 +799,7 @@ impl SettingsWindow { .on_click(smaller), ) .child( - Label::new(format!("{} px", settings.ui_font_size)).size(LabelSize::Small), + Label::new(format!("{} px", settings.ui_font_size)).size(UI_LABEL_DEFAULT), ) .child( IconButton::new("ui-font-larger", IconName::Plus) @@ -856,7 +856,7 @@ impl SettingsWindow { ) .child( Label::new(format!("{} px", settings.terminal_font_size)) - .size(LabelSize::Small), + .size(UI_LABEL_DEFAULT), ) .child( IconButton::new("terminal-font-larger", IconName::Plus) @@ -908,7 +908,7 @@ impl SettingsWindow { cx.notify(); }); table.row(vec![ - Label::new(action.title()).size(LabelSize::Small).into_any_element(), + Label::new(action.title()).size(UI_LABEL_DEFAULT).into_any_element(), Button::new( format!("record-hotkey-{}", action.id()), if recording == Some(action) { @@ -917,7 +917,6 @@ impl SettingsWindow { keymap.key(action).to_owned() }, ) - .label_size(LabelSize::Default) .toggle_state(recording == Some(action)) .on_click(capture) .into_any_element(), @@ -931,7 +930,7 @@ impl SettingsWindow { view.child( Banner::new() .severity(Severity::Error) - .child(Label::new(problem).size(LabelSize::Small)), + .child(Label::new(problem).size(UI_LABEL_DEFAULT)), ) }) .when(self.keymap_restart_required, |view| { @@ -939,14 +938,14 @@ impl SettingsWindow { Label::new( "Shortcut changes are saved. Restart Chartr to rebuild the application keymap.", ) - .size(LabelSize::Small), + .size(UI_LABEL_DEFAULT), )) }) .child( Label::new( "Click a shortcut, then press one key chord. Conflicts in the Chartr context are rejected.", ) - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ) .child(table) @@ -1038,10 +1037,10 @@ impl SettingsWindow { .justify_between() .child( v_flex() - .child(Label::new(manifest.name).size(LabelSize::Small)) + .child(Label::new(manifest.name).size(UI_LABEL_DEFAULT)) .child( Label::new(manifest.id) - .size(LabelSize::XSmall) + .size(UI_LABEL_SMALL) .color(Color::Muted), ), ) @@ -1055,7 +1054,7 @@ impl SettingsWindow { .on_click(toggle), ), ) - .child(Label::new(trust).size(LabelSize::XSmall).color(Color::Muted)) + .child(Label::new(trust).size(UI_LABEL_SMALL).color(Color::Muted)) .when_some(configure, |row, control| row.child(control)) .when_some(unsafe_control, |row, control| row.child(control)) }) @@ -1065,7 +1064,7 @@ impl SettingsWindow { .map(|rejected| { Banner::new().severity(Severity::Error).child( Label::new(format!("{}: {}", rejected.dir.display(), rejected.why)) - .size(LabelSize::XSmall), + .size(UI_LABEL_SMALL), ) }) .collect(); @@ -1078,7 +1077,7 @@ impl SettingsWindow { Label::new( "Open Settings from a Chartr workspace to manage runtime plugins.", ) - .size(LabelSize::Small), + .size(UI_LABEL_DEFAULT), ), ) }) @@ -1099,6 +1098,7 @@ impl Focusable for SettingsWindow { impl Render for SettingsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = Fonts::setup_ui(window, cx); let selected = self.page; let navigation: Vec<_> = SettingsPage::ALL .into_iter() @@ -1128,7 +1128,7 @@ impl Render for SettingsWindow { } cx.notify(); })) - .child(Label::new(page.title()).size(LabelSize::Small)) + .child(Label::new(page.title()).size(UI_LABEL_DEFAULT)) }) .collect(); let unreadable = cx.global::().unreadable().map(str::to_owned); @@ -1140,6 +1140,8 @@ impl Render for SettingsWindow { .key_context("ChartrSettings") .track_focus(&self.focus) .size_full() + .font(ui_font) + .text_size(UI_TEXT_DEFAULT) .bg(cx.theme().colors().background) .text_color(cx.theme().colors().text) .on_action(cx.listener(|_, _: &Close, window, _| window.remove_window())) @@ -1162,12 +1164,12 @@ impl Render for SettingsWindow { .child( div().px_3().pb_2().child( Label::new("Settings") - .size(LabelSize::Large) + .size(UI_LABEL_LARGE) .weight(FontWeight::SEMIBOLD), ), ) .child(div().px_3().py_1().child( - Label::new("Options").size(LabelSize::XSmall).color(Color::Muted), + Label::new("Options").size(UI_LABEL_SMALL).color(Color::Muted), )) .children(navigation), ) @@ -1184,19 +1186,19 @@ impl Render for SettingsWindow { .max_w(px(720.)) .p_6() .gap_4() - .child(Label::new(page_title).size(LabelSize::Large)) + .child(Label::new(page_title).size(UI_LABEL_LARGE)) .when_some(unreadable, |view, problem| { view.child( Banner::new() .severity(Severity::Error) - .child(Label::new(problem).size(LabelSize::Small)), + .child(Label::new(problem).size(UI_LABEL_DEFAULT)), ) }) .when_some(self.problem.clone(), |view, problem| { view.child( Banner::new() .severity(Severity::Error) - .child(Label::new(problem).size(LabelSize::Small)), + .child(Label::new(problem).size(UI_LABEL_DEFAULT)), ) }) .child(content), @@ -1207,15 +1209,15 @@ impl Render for SettingsWindow { } fn setting_label(label: &'static str) -> AnyElement { - Label::new(label).size(LabelSize::Small).color(Color::Muted).into_any_element() + Label::new(label).size(UI_LABEL_DEFAULT).color(Color::Muted).into_any_element() } fn setting_value(label: &'static str, value: String) -> AnyElement { h_flex() .justify_between() .gap_4() - .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) - .child(Label::new(value).size(LabelSize::Small)) + .child(Label::new(label).size(UI_LABEL_DEFAULT).color(Color::Muted)) + .child(Label::new(value).size(UI_LABEL_DEFAULT)) .into_any_element() } From e25aaba14597c598842ad7ecaf4304f00d15f0b4 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 16:04:15 +0800 Subject: [PATCH 010/110] Refine tab dragging and pane chrome --- README.md | 14 +- crates/zeddy/src/actions.rs | 24 +--- crates/zeddy/src/app.rs | 205 ++++------------------------ crates/zeddy/src/chrome.rs | 55 ++++++-- crates/zeddy/src/chrome/sidebar.rs | 197 ++++++++++++++++---------- crates/zeddy/src/chrome/tabs.rs | 3 +- crates/zeddy/src/components.rs | 20 +++ crates/zeddy/src/keymap.rs | 10 +- crates/zeddy/src/main.rs | 1 + crates/zeddy/src/settings.rs | 59 ++++++-- crates/zeddy/src/settings_window.rs | 28 ++-- crates/zeddy/src/space.rs | 49 ------- crates/zeddy/src/workspace.rs | 98 +------------ docs/acceptance.md | 11 +- 14 files changed, 293 insertions(+), 481 deletions(-) create mode 100644 crates/zeddy/src/components.rs diff --git a/README.md b/README.md index cdcdc613..1d98d76a 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,15 @@ multiple instances, modifier cloning, restoration, and explicit binding to one terminal session. Panes support nested horizontal and vertical splits, divider resizing, -directional focus, joining, zooming, and Zed-style tab dragging. Tab and +directional focus, joining, and Zed-style tab dragging. Tab and trailing-strip drops reorder or move items; pane-body center drops move into a pane; the four edge targets split it, with Zed's transient full/half-pane highlight. Escape cancels a drag. The command palette provides keyboard -alternatives for pane operations. `Cmd+W` on macOS and `Ctrl+W` on Linux closes -the active item; operations that terminate multiple live sessions confirm with -an exact count. As in Zed, a non-root pane disappears when its last item leaves; -an outer tab disappears when its final item closes. An empty space remains -usable through its New action. Splitting a lone-tab pane uses Zed's -opposite-empty-pane rule, keeping the tab focused and leaving the requested side -available as a drop target. +alternatives for pane navigation and moving or joining items. `Cmd+W` on macOS +and `Ctrl+W` on Linux closes the active item; operations that terminate multiple +live sessions confirm with an exact count. As in Zed, a non-root pane disappears +when its last item leaves; an outer tab disappears when its final item closes. +An empty space remains usable through its New action. Sidebar and tabbed modes are projections over that same model. Both list every standalone item and every pane group as one outer entry. Sidebar mode can show diff --git a/crates/zeddy/src/actions.rs b/crates/zeddy/src/actions.rs index f6695823..34a48668 100644 --- a/crates/zeddy/src/actions.rs +++ b/crates/zeddy/src/actions.rs @@ -10,33 +10,14 @@ use crate::keymap::{KeymapAction, KeymapStore}; pub mod pane { gpui::actions!( pane, - [ - CloseActiveItem, - CloseAllItems, - JoinIntoNext, - SplitAndMoveLeft, - SplitAndMoveRight, - SplitAndMoveUp, - SplitAndMoveDown, - MoveLeft, - MoveRight, - MoveUp, - MoveDown - ] + [CloseActiveItem, CloseAllItems, JoinIntoNext, MoveLeft, MoveRight, MoveUp, MoveDown] ); } pub mod workspace { gpui::actions!( workspace, - [ - NewTerminal, - ActivatePaneLeft, - ActivatePaneRight, - ActivatePaneUp, - ActivatePaneDown, - ToggleZoom - ] + [NewTerminal, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePaneDown] ); } @@ -61,7 +42,6 @@ pub fn init(keymap: &KeymapStore, cx: &mut App) { ), KeyBinding::new(keymap.key(KeymapAction::FocusUp), workspace::ActivatePaneUp, context), KeyBinding::new(keymap.key(KeymapAction::FocusDown), workspace::ActivatePaneDown, context), - KeyBinding::new(keymap.key(KeymapAction::ToggleZoom), workspace::ToggleZoom, context), KeyBinding::new(keymap.key(KeymapAction::CommandPalette), command_palette::Toggle, context), KeyBinding::new(keymap.key(KeymapAction::OpenSettings), settings::Open, context), ]); diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 2346ef0c..09e1e4c8 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -85,10 +85,6 @@ enum PaletteCommand { NewTerminal, CloseItem, CloseAllItems, - SplitLeft, - SplitRight, - SplitUp, - SplitDown, MoveLeft, MoveRight, MoveUp, @@ -98,19 +94,14 @@ enum PaletteCommand { FocusRight, FocusUp, FocusDown, - ToggleZoom, OpenSettings, } impl PaletteCommand { - const ALL: [(Self, &'static str, &'static str); 18] = [ + const ALL: [(Self, &'static str, &'static str); 13] = [ (Self::NewTerminal, "Workspace: New Terminal", "Ctrl+~"), (Self::CloseItem, "Pane: Close Active Item", "Cmd/Ctrl+W"), (Self::CloseAllItems, "Pane: Close All Items", ""), - (Self::SplitLeft, "Pane: Split and Move Left", ""), - (Self::SplitRight, "Pane: Split and Move Right", ""), - (Self::SplitUp, "Pane: Split and Move Up", ""), - (Self::SplitDown, "Pane: Split and Move Down", ""), (Self::MoveLeft, "Pane: Move Active Item Left", ""), (Self::MoveRight, "Pane: Move Active Item Right", ""), (Self::MoveUp, "Pane: Move Active Item Up", ""), @@ -120,7 +111,6 @@ impl PaletteCommand { (Self::FocusRight, "Pane: Focus Right", "Cmd/Ctrl+K →"), (Self::FocusUp, "Pane: Focus Up", "Cmd/Ctrl+K ↑"), (Self::FocusDown, "Pane: Focus Down", "Cmd/Ctrl+K ↓"), - (Self::ToggleZoom, "Pane: Toggle Zoom", "Shift+Esc"), (Self::OpenSettings, "Chartr: Open Settings", "Cmd/Ctrl+,"), ]; } @@ -1288,26 +1278,6 @@ impl Zeddy { cx.notify(); } - fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context) { - if let Some(space) = self.active.clone() { - space.update(cx, |space, _| space.split_and_move(direction)); - cx.notify(); - } - } - - fn split_and_move_in( - &mut self, - tab: WorkspaceTabId, - pane: LayoutPaneId, - direction: SplitDirection, - cx: &mut Context, - ) { - if let Some(space) = self.active.clone() { - space.update(cx, |space, _| space.split_and_move_in(tab, pane, direction)); - cx.notify(); - } - } - fn move_active_to_pane(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(space) = self.active.clone() { space.update(cx, |space, _| space.move_active_to_pane(direction)); @@ -1335,20 +1305,6 @@ impl Zeddy { } } - fn toggle_zoom(&mut self, cx: &mut Context) { - if let Some(space) = self.active.clone() { - space.update(cx, |space, _| space.toggle_zoom()); - cx.notify(); - } - } - - fn toggle_zoom_in(&mut self, tab: WorkspaceTabId, pane: LayoutPaneId, cx: &mut Context) { - if let Some(space) = self.active.clone() { - space.update(cx, |space, _| space.toggle_zoom_in(tab, pane)); - cx.notify(); - } - } - fn toggle_command_palette(&mut self, window: &mut Window, cx: &mut Context) { self.command_palette_open = !self.command_palette_open; self.command_palette_query.clear(); @@ -1383,10 +1339,6 @@ impl Zeddy { PaletteCommand::NewTerminal => Box::new(actions::workspace::NewTerminal), PaletteCommand::CloseItem => Box::new(actions::pane::CloseActiveItem), PaletteCommand::CloseAllItems => Box::new(actions::pane::CloseAllItems), - PaletteCommand::SplitLeft => Box::new(actions::pane::SplitAndMoveLeft), - PaletteCommand::SplitRight => Box::new(actions::pane::SplitAndMoveRight), - PaletteCommand::SplitUp => Box::new(actions::pane::SplitAndMoveUp), - PaletteCommand::SplitDown => Box::new(actions::pane::SplitAndMoveDown), PaletteCommand::MoveLeft => Box::new(actions::pane::MoveLeft), PaletteCommand::MoveRight => Box::new(actions::pane::MoveRight), PaletteCommand::MoveUp => Box::new(actions::pane::MoveUp), @@ -1396,7 +1348,6 @@ impl Zeddy { PaletteCommand::FocusRight => Box::new(actions::workspace::ActivatePaneRight), PaletteCommand::FocusUp => Box::new(actions::workspace::ActivatePaneUp), PaletteCommand::FocusDown => Box::new(actions::workspace::ActivatePaneDown), - PaletteCommand::ToggleZoom => Box::new(actions::workspace::ToggleZoom), PaletteCommand::OpenSettings => Box::new(actions::settings::Open), }; window.dispatch_action(action, cx); @@ -1909,6 +1860,13 @@ impl Zeddy { let Some(space) = self.active.clone() else { return; }; + if dragged.grouped { + space.update(cx, |space, _| { + space.clear_drag_target(); + }); + cx.notify(); + return; + } if space.read(cx).key() != dragged.space { space.update(cx, |space, _| { space.clear_drag_target(); @@ -1967,32 +1925,18 @@ impl Zeddy { let workspace = if let Some(tab) = space.workspace_tabs().active_tab() { let layout = &tab.layout; let show_pane_headers = tab.is_grouped(); - if let Some(maximized) = layout.center.maximized { - self.render_pane( - &space, - tab.id, - layout, - maximized, - show_pane_headers, - &emit, - &weak, - window, - cx, - ) - } else { - self.render_member( - &space, - tab.id, - layout, - &layout.center.root, - show_pane_headers, - &emit, - &weak, - &[], - window, - cx, - ) - } + self.render_member( + &space, + tab.id, + layout, + &layout.center.root, + show_pane_headers, + &emit, + &weak, + &[], + window, + cx, + ) } else { message("No tabs. Create a new item to begin.", cx).into_any_element() }; @@ -2347,7 +2291,8 @@ impl Zeddy { // callbacks from every pane except the one under the pointer. return; }; - let accepted = event.drag(cx).space == drag_space; + let dragged = event.drag(cx); + let accepted = !dragged.grouped && dragged.space == drag_space; let _ = drag_move.update(cx, |this, cx| { let changed = this.active.clone().is_some_and(|space| { space.update(cx, |space, _| { @@ -2457,9 +2402,8 @@ impl Zeddy { pane: pane_id, index, item: *id, - title: item.title(), - selected, top_level: false, + grouped: false, }; Some( Tab::new(format!("pane-{}-item-{}", pane_id.get(), id.get())) @@ -2477,7 +2421,7 @@ impl Zeddy { .can_drop(move |value, _, _| { value .downcast_ref::() - .is_some_and(|dragged| dragged.space == drop_space) + .is_some_and(|dragged| !dragged.grouped && dragged.space == drop_space) }) .drag_over::(move |tab, dragged, _, cx| { let mut tab = tab @@ -2538,7 +2482,7 @@ impl Zeddy { .can_drop(move |value, _, _| { value .downcast_ref::() - .is_some_and(|dragged| dragged.space == append_space) + .is_some_and(|dragged| !dragged.grouped && dragged.space == append_space) }) .drag_over::(|bar, _, _, cx| { bar.bg(cx.theme().colors().drop_target_background) @@ -2560,7 +2504,6 @@ impl Zeddy { TabBar::new(format!("workspace-tab-{}-pane-{}-tabs", tab_id.get(), pane_id.get())) .children(tabs) .child(tab_bar_drop_target) - .end_child(pane_controls(weak, tab_id, pane_id)) .into_any_element() } @@ -2834,18 +2777,6 @@ impl Render for Zeddy { .on_action(cx.listener(|this, _: &actions::pane::CloseAllItems, window, cx| { this.request_close_active_pane(window, cx) })) - .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveLeft, _, cx| { - this.split_and_move(SplitDirection::Left, cx) - })) - .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveRight, _, cx| { - this.split_and_move(SplitDirection::Right, cx) - })) - .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveUp, _, cx| { - this.split_and_move(SplitDirection::Up, cx) - })) - .on_action(cx.listener(|this, _: &actions::pane::SplitAndMoveDown, _, cx| { - this.split_and_move(SplitDirection::Down, cx) - })) .on_action(cx.listener(|this, _: &actions::pane::MoveLeft, _, cx| { this.move_active_to_pane(SplitDirection::Left, cx) })) @@ -2875,9 +2806,6 @@ impl Render for Zeddy { .on_action(cx.listener(|this, _: &actions::workspace::ActivatePaneDown, window, cx| { this.activate_pane_in_direction(SplitDirection::Down, window, cx) })) - .on_action( - cx.listener(|this, _: &actions::workspace::ToggleZoom, _, cx| this.toggle_zoom(cx)), - ) .on_action(cx.listener(|this, _: &actions::workspace::NewTerminal, window, cx| { this.act(Action::New, window, cx) })) @@ -2946,7 +2874,9 @@ fn drop_target(direction: Option, group: String, space: String, .absolute() .bg(cx.theme().colors().drop_target_background) .can_drop(move |value, _, _| { - value.downcast_ref::().is_some_and(|dragged| dragged.space == space) + value + .downcast_ref::() + .is_some_and(|dragged| !dragged.grouped && dragged.space == space) }) .group_drag_over::(group, |style| style.visible()) .map(|target| match direction { @@ -2976,85 +2906,6 @@ fn pane_resize_handle(dragged: DraggedPaneDivider, axis: PaneAxisDirection) -> i .occlude() } -fn pane_controls( - weak: &gpui::WeakEntity, - tab_id: WorkspaceTabId, - pane_id: LayoutPaneId, -) -> AnyElement { - let focus = weak.clone(); - let split = weak.clone(); - let zoom = weak.clone(); - - h_flex() - .id(format!("workspace-tab-{}-pane-{}-controls", tab_id.get(), pane_id.get())) - .gap_0p5() - .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| { - let _ = focus.update(cx, |this, cx| { - if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.activate_pane(tab_id, pane_id)); - } - cx.notify(); - }); - }) - .child( - PopoverMenu::new(format!( - "workspace-tab-{}-pane-{}-split-menu", - tab_id.get(), - pane_id.get() - )) - .trigger_with_tooltip( - IconButton::new( - format!("workspace-tab-{}-pane-{}-split", tab_id.get(), pane_id.get()), - IconName::Split, - ) - .icon_size(IconSize::XSmall), - Tooltip::text("Split Pane"), - ) - .anchor(Anchor::TopRight) - .menu(move |window, cx| { - let split = split.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - let left = split.clone(); - let right = split.clone(); - let up = split.clone(); - let down = split.clone(); - menu.entry("Split Left", None, move |_, cx| { - let _ = left.update(cx, |this, cx| { - this.split_and_move_in(tab_id, pane_id, SplitDirection::Left, cx) - }); - }) - .entry("Split Right", None, move |_, cx| { - let _ = right.update(cx, |this, cx| { - this.split_and_move_in(tab_id, pane_id, SplitDirection::Right, cx) - }); - }) - .entry("Split Up", None, move |_, cx| { - let _ = up.update(cx, |this, cx| { - this.split_and_move_in(tab_id, pane_id, SplitDirection::Up, cx) - }); - }) - .entry("Split Down", None, move |_, cx| { - let _ = down.update(cx, |this, cx| { - this.split_and_move_in(tab_id, pane_id, SplitDirection::Down, cx) - }); - }) - })) - }), - ) - .child( - IconButton::new( - format!("workspace-tab-{}-pane-{}-zoom", tab_id.get(), pane_id.get()), - IconName::Maximize, - ) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Toggle Pane Zoom")) - .on_click(move |_, _, cx| { - let _ = zoom.update(cx, |this, cx| this.toggle_zoom_in(tab_id, pane_id, cx)); - }), - ) - .into_any_element() -} - fn load_registry(cwd: &std::path::Path) -> (Option, Option) { let file = match spaces::spaces_file() { Ok(file) => file, diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 7ea71b62..f0dbac26 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -15,7 +15,7 @@ use crate::{ workspace::{ItemId, PaneId, WorkspaceTabId}, }; use gpui::EntityId; -use ui::{CommonAnimationExt, Tab, prelude::*}; +use ui::{CommonAnimationExt, prelude::*}; use zeddy_herdr::control::SessionStatus; /// One row in the sidebar, or one tab in the strip. @@ -89,27 +89,51 @@ pub struct DraggedItem { pub pane: PaneId, pub index: usize, pub item: ItemId, - pub title: String, - pub selected: bool, pub top_level: bool, + /// The drag represents the whole outer workspace tab, not its + /// representative item. Grouped tabs may be sorted by outer chrome, but + /// cannot be dropped into an individual pane as though they were one item. + pub grouped: bool, } impl Render for DraggedItem { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Tab::new(("dragged-item", self.item.get() as usize)) - .toggle_state(self.selected) - .child(Label::new(self.title.clone()).size(UI_LABEL_DEFAULT)) + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + dragged_item_pill(self.grouped, cx) } } +const DRAGGED_ITEM_PILL_WIDTH: f32 = 44.; +const DRAGGED_GROUP_PILL_WIDTH: f32 = 60.; +const DRAGGED_ITEM_PILL_HEIGHT: f32 = 22.; + +fn dragged_item_pill_width(grouped: bool) -> f32 { + if grouped { DRAGGED_GROUP_PILL_WIDTH } else { DRAGGED_ITEM_PILL_WIDTH } +} + +fn dragged_item_pill(grouped: bool, cx: &App) -> impl IntoElement { + let colors = cx.theme().colors(); + div() + .flex() + .items_center() + .justify_center() + .w(px(dragged_item_pill_width(grouped))) + .h(px(DRAGGED_ITEM_PILL_HEIGHT)) + .rounded_full() + .border_1() + .border_color(colors.border) + .bg(colors.elevated_surface_background) + .shadow_md() + .child(Label::new(if grouped { "group" } else { "tab" }).size(UI_LABEL_DEFAULT)) +} + /// Builds the one drag preview used by every Chartr tab surface. /// /// GPUI positions a drag view at `pointer - offset_within_source`, which is /// perfect when the preview has the source element's dimensions. Chartr's /// sidebar rows and outer tabs are often much wider than the compact preview, /// though, so using the source offset makes the visible ghost trail behind the -/// pointer. Translating the compact preview by that same offset locks its -/// visible origin to GPUI's current-frame pointer position. +/// pointer. Translating the compact preview by that same offset and half of +/// its own size locks its center to GPUI's current-frame pointer position. pub(crate) fn dragged_item_preview( dragged: &DraggedItem, source_offset: gpui::Point, @@ -125,12 +149,13 @@ pub(crate) struct DraggedItemPreview { } impl Render for DraggedItemPreview { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().relative().left(self.source_offset.x).top(self.source_offset.y).child( - Tab::new(("dragged-item-preview", self.dragged.item.get() as usize)) - .toggle_state(self.dragged.selected) - .child(Label::new(self.dragged.title.clone()).size(UI_LABEL_DEFAULT)), - ) + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let width = dragged_item_pill_width(self.dragged.grouped); + div() + .relative() + .left(self.source_offset.x - px(width / 2.)) + .top(self.source_offset.y - px(DRAGGED_ITEM_PILL_HEIGHT / 2.)) + .child(dragged_item_pill(self.dragged.grouped, cx)) } } diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 70a05c4f..33b77a81 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -13,6 +13,7 @@ use super::{ Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, status_indicator, }; +use crate::components::{selection_list, selection_row}; use crate::fonts::{UI_LABEL_DEFAULT, UI_LABEL_SMALL}; /// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a @@ -125,10 +126,18 @@ pub fn render( ) .into_any_element(), ); - for entry in &space.entries { + for (target_index, entry) in space.entries.iter().enumerate() { groups.push( - row(index, entry, space.active && entry.selected, entry.grouped, on.clone(), cx) - .into_any_element(), + row( + index, + target_index, + entry, + space.active && entry.selected, + entry.grouped, + on.clone(), + cx, + ) + .into_any_element(), ); index += 1; } @@ -144,12 +153,22 @@ pub fn render( .border_r_1() .border_color(colors.border) .child(header(space_switcher, on.clone())) - .child(v_flex().id("sessions").flex_1().overflow_y_scroll().p_1().gap_px().children(groups)) + .child( + selection_list() + .id("sessions") + .flex_1() + .overflow_y_scroll() + .py_1() + .px_1() + .children(groups), + ) .child(deferred( div() .id("sidebar-resize-handle") .absolute() - .right(px(-3.)) + // Keep the resize target fully outside the sidebar so it + // cannot occlude a trailing row action at the panel boundary. + .right(px(-6.)) .top_0() .h_full() .w(px(6.)) @@ -182,97 +201,137 @@ fn header(space_switcher: AnyElement, on: Emit) -> impl IntoElement { fn row( index: usize, + target_index: usize, entry: &Entry, selected: bool, grouped: bool, on: Emit, cx: &App, ) -> impl IntoElement { - let colors = cx.theme().colors(); let close = on.clone(); + let move_tab = on.clone(); let select = entry.key; let close_key = entry.key; let close_tab = entry.tab; let space = entry.space; let close_space = entry.space; + let target_space_key = entry.space_key.clone(); let dragged = DraggedItem { space: entry.space_key.clone(), tab: entry.tab, pane: entry.pane, - index: entry.index, + // A sidebar row is an outer workspace tab. Its drag index therefore + // belongs to the space's outer list, not to the representative item's + // position inside its pane. + index: target_index, item: entry.key, - title: entry.title.clone(), - selected, top_level: true, + grouped, }; - h_flex() - .id(("session", index)) - .role(Role::Tab) - .aria_label(if grouped { - format!("Pane group: {}", entry.title) - } else { - entry.title.clone() + let close_button_width = IconSize::XSmall.rems() + DynamicSpacing::Base04.rems(cx) * 2.; + let close_slot_width = close_button_width - DynamicSpacing::Base06.rems(cx); + let end_slot = h_flex() + .gap_1() + .when(grouped, |slot| { + slot.child( + Label::new(format!("{} tabs", entry.item_count)) + .size(UI_LABEL_SMALL) + .color(Color::Muted), + ) }) - .aria_selected(selected) + .when(entry.closable, |slot| { + // Reserve exactly the portion of the button not already covered + // by ListItem's trailing Base06 inset. The real control is an + // unclipped overlay at the wrapper level below. + slot.child(div().w(close_slot_width).flex_none()) + }); + let close_button = entry.closable.then(|| { + IconButton::new(("close", index), IconName::Close).icon_size(IconSize::XSmall).on_click( + move |_, window, cx| { + cx.stop_propagation(); + close( + if grouped { + Action::CloseGroup { space: close_space, tab: close_tab } + } else { + Action::Close { space: Some(close_space), item: close_key } + }, + window, + cx, + ) + }, + ) + }); + + // `ListItem` deliberately owns row visuals and click semantics. This thin + // wrapper owns sidebar-tab dragging, which Zed's generic row does not. + div() + .id(("session-drag", index)) + .relative() .group("session") - // The close button's standard Zed control height is the row's natural - // minimum. Let content establish that compact height, then keep it - // from flex-shrinking further when the list scrolls. + .w_full() .flex_none() - .px_2() - .gap_2() - .rounded_sm() - .when(selected, |row| row.bg(colors.element_selected)) - .when(!selected, |row| row.hover(|row| row.bg(colors.element_hover))) - .on_click(move |_, window, cx| { - on(Action::Select { space: Some(space), item: select }, window, cx) + .on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) + // Like both earlier Chartr clients, sorting stays within the card/space + // where the drag began. Pane-local tab drags are rejected as well: this + // surface only reorders top-level workspace tabs. + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| dragged.space == target_space_key && dragged.top_level) }) - .when(!grouped, |row| { - row.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) + .drag_over::(move |wrapper, dragged, _, cx| { + let mut wrapper = wrapper + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + if target_index < dragged.index { + wrapper = wrapper.border_t_2(); + } else if target_index > dragged.index { + wrapper = wrapper.border_b_2(); + } + wrapper + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + move_tab(Action::MoveWorkspaceTab { space, tab: dragged.tab, target_index }, window, cx) }) - .child(status_indicator( - entry.status, - entry.process_running, - entry.ended, - entry.grouped, - &entry.space_key, - entry.key, - cx, - )) .child( - v_flex() - .flex_1() - .overflow_hidden() - .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()), + selection_row(("session", index), selected) + .aria_role(Role::Tab) + .aria_label(if grouped { + format!("Pane group: {}", entry.title) + } else { + entry.title.clone() + }) + .on_click(move |_, window, cx| { + on(Action::Select { space: Some(space), item: select }, window, cx) + }) + .start_slot(status_indicator( + entry.status, + entry.process_running, + entry.ended, + entry.grouped, + &entry.space_key, + entry.key, + cx, + )) + .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()) + .end_slot(end_slot), ) - .when(grouped, |row| { - row.child( - Label::new(format!("{} tabs", entry.item_count)) - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ) - }) - .when(entry.closable, |row| { - row.child( - // Revealed on hover so a list of ten sessions is ten titles rather - // than ten titles and ten buttons. - div().visible_on_hover("session").child( - IconButton::new(("close", index), IconName::Close) - .icon_size(IconSize::XSmall) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - close( - if grouped { - Action::CloseGroup { space: close_space, tab: close_tab } - } else { - Action::Close { space: Some(close_space), item: close_key } - }, - window, - cx, - ) - }), - ), + .when_some(close_button, |wrapper, close_button| { + wrapper.child( + div() + .absolute() + .right_0() + .top_0() + .bottom_0() + .flex() + .items_center() + .visible_on_hover("session") + // A press on Close belongs to the control, not the row's + // drag recognizer; the button handles the resulting click. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(close_button), ) }) } diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index ad31f5ec..864fc1e3 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -88,9 +88,8 @@ fn tab( pane: entry.pane, index, item: entry.key, - title: entry.title.clone(), - selected: entry.selected, top_level: true, + grouped: entry.grouped, }; let close_slot: Option = entry.closable.then(|| { IconButton::new(("close", index), IconName::Close) diff --git a/crates/zeddy/src/components.rs b/crates/zeddy/src/components.rs new file mode 100644 index 00000000..165d5c50 --- /dev/null +++ b/crates/zeddy/src/components.rs @@ -0,0 +1,20 @@ +//! Small Chartr defaults around Zed's reusable UI components. +//! +//! Content and behavior stay with their owning feature; only visual contracts +//! shared across features belong here. + +use gpui::{Div, ElementId}; +use ui::{ListItem, ListItemSpacing, prelude::*}; + +/// A vertical collection of selectable rows. The inter-row gap is part of the +/// collection rather than any individual row, so adjacent state backgrounds +/// are always separated consistently. +pub fn selection_list() -> Div { + v_flex().gap_px() +} + +/// Chartr's common selectable-row treatment, backed by Zed's `ListItem` so +/// padding, corners, and interaction-state colors follow the component theme. +pub fn selection_row(id: impl Into, selected: bool) -> ListItem { + ListItem::new(id).spacing(ListItemSpacing::Sparse).rounded().toggle_state(selected) +} diff --git a/crates/zeddy/src/keymap.rs b/crates/zeddy/src/keymap.rs index f205a2e0..dad76e3c 100644 --- a/crates/zeddy/src/keymap.rs +++ b/crates/zeddy/src/keymap.rs @@ -25,20 +25,18 @@ pub enum KeymapAction { FocusRight, FocusUp, FocusDown, - ToggleZoom, CommandPalette, OpenSettings, } impl KeymapAction { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 8] = [ Self::CloseItem, Self::NewTerminal, Self::FocusLeft, Self::FocusRight, Self::FocusUp, Self::FocusDown, - Self::ToggleZoom, Self::CommandPalette, Self::OpenSettings, ]; @@ -51,7 +49,6 @@ impl KeymapAction { Self::FocusRight => "workspace.activate_pane_right", Self::FocusUp => "workspace.activate_pane_up", Self::FocusDown => "workspace.activate_pane_down", - Self::ToggleZoom => "workspace.toggle_zoom", Self::CommandPalette => "command_palette.toggle", Self::OpenSettings => "settings.open", } @@ -65,7 +62,6 @@ impl KeymapAction { Self::FocusRight => "Focus pane right", Self::FocusUp => "Focus pane up", Self::FocusDown => "Focus pane down", - Self::ToggleZoom => "Toggle pane zoom", Self::CommandPalette => "Command palette", Self::OpenSettings => "Open Settings", } @@ -80,7 +76,6 @@ impl KeymapAction { Self::FocusRight => "cmd-k cmd-right", Self::FocusUp => "cmd-k cmd-up", Self::FocusDown => "cmd-k cmd-down", - Self::ToggleZoom => "shift-escape", Self::CommandPalette => "cmd-shift-p", Self::OpenSettings => "cmd-,", }; @@ -93,7 +88,6 @@ impl KeymapAction { Self::FocusRight => "ctrl-k ctrl-right", Self::FocusUp => "ctrl-k ctrl-up", Self::FocusDown => "ctrl-k ctrl-down", - Self::ToggleZoom => "shift-escape", Self::CommandPalette => "ctrl-shift-p", Self::OpenSettings => "ctrl-,", }; @@ -244,7 +238,7 @@ mod tests { store.set(KeymapAction::CloseItem, "ctrl-alt-w".to_owned()).unwrap(); let loaded = KeymapStore::load(file); assert_eq!(loaded.key(KeymapAction::CloseItem), "ctrl-alt-w"); - assert_eq!(loaded.key(KeymapAction::ToggleZoom), KeymapAction::ToggleZoom.default_key()); + assert_eq!(loaded.key(KeymapAction::FocusLeft), KeymapAction::FocusLeft.default_key()); } #[test] diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 21a936ee..15c4c006 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -10,6 +10,7 @@ use gpui_platform::application; mod actions; mod app; mod chrome; +mod components; mod fonts; mod item; mod keymap; diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index 7ba0ab86..b2d66761 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -357,9 +357,7 @@ pub fn init_themes(settings: &ResolvedSettings, cx: &mut gpui::App) { })); } if let Some(source) = dark_source { - let mut dark = (*source).clone(); - dark.id = "chartr_dark".to_owned(); - dark.name = CHARTR_DARK.into(); + let dark = chartr_dark(&source); let light = chartr_light(&dark); registry.insert_themes([dark, light]); } @@ -729,15 +727,20 @@ fn catalog_theme(source: &Theme, palette: ThemePalette) -> Theme { let colors = &mut theme.styles.colors; colors.background = surface; colors.surface_background = sidebar; - colors.elevated_surface_background = card_open; + // `card_open` is the palette's active/selected card color. Using it for + // the whole elevated surface makes context-menu borders disappear in + // palettes where `card_open` intentionally matches `border` (notably the + // Catppuccin themes). Keep the menu on the normal card surface so both its + // outline and its selected row retain contrast. + colors.elevated_surface_background = card; colors.element_background = card; colors.element_hover = hover; - colors.element_active = selected; - colors.element_selected = selected; + colors.element_active = card_open; + colors.element_selected = card_open; colors.element_selection_background = selected; colors.ghost_element_hover = hover; - colors.ghost_element_active = selected; - colors.ghost_element_selected = selected; + colors.ghost_element_active = card_open; + colors.ghost_element_selected = card_open; colors.drop_target_background = selected; colors.drop_target_border = ring; colors.border = border; @@ -796,6 +799,23 @@ fn catalog_theme(source: &Theme, palette: ThemePalette) -> Theme { theme } +fn chartr_dark(source: &Theme) -> Theme { + let mut dark = source.clone(); + dark.id = "chartr_dark".to_owned(); + dark.name = CHARTR_DARK.into(); + dark.appearance = Appearance::Dark; + + let colors = &mut dark.styles.colors; + let border = gpui::rgb(0x505866).into(); + let border_variant = gpui::rgb(0x414956).into(); + colors.border = border; + colors.border_variant = border_variant; + colors.pane_group_border = border; + colors.panel_indent_guide = border_variant; + colors.scrollbar_track_border = border_variant; + dark +} + fn chartr_light(dark: &Theme) -> Theme { let mut light = dark.clone(); light.id = "chartr_light".to_owned(); @@ -823,6 +843,9 @@ fn chartr_light(dark: &Theme) -> Theme { colors.ghost_element_selected = selected; colors.border = border; colors.border_variant = border; + colors.pane_group_border = border; + colors.panel_indent_guide = border; + colors.scrollbar_track_border = border; colors.text = text; colors.text_muted = muted; colors.text_placeholder = muted; @@ -878,12 +901,32 @@ mod tests { for palette in THEME_PALETTES { let registered = registry.get(palette.name).unwrap(); assert_eq!(registered.appearance, palette.appearance); + assert_ne!( + registered.styles.colors.elevated_surface_background, + registered.styles.colors.border_variant, + "{} must retain a visible elevated-surface border", + palette.name, + ); } assert_eq!(registry.get(CHARTR_DARK).unwrap().appearance, Appearance::Dark); assert_eq!(registry.get(CHARTR_LIGHT).unwrap().appearance, Appearance::Light); }); } + #[gpui::test] + fn chartr_dark_keeps_structural_borders_clear_of_its_surfaces(cx: &mut TestAppContext) { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + init_themes(&ResolvedSettings::default(), cx); + let theme = ThemeRegistry::global(cx).get(CHARTR_DARK).unwrap(); + let colors = &theme.styles.colors; + + assert!((colors.border.l - colors.editor_background.l).abs() >= 0.15); + assert!((colors.border_variant.l - colors.elevated_surface_background.l).abs() >= 0.08); + assert_eq!(colors.pane_group_border, colors.border); + }); + } + #[test] fn a_missing_file_resolves_to_fixed_chartr_dark() { let scratch = tempfile::tempdir().unwrap(); diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index 83fe572b..04189911 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -18,6 +18,7 @@ use ui::{ use crate::{ app::Zeddy, + components::{selection_list, selection_row}, fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, keymap::{KeymapAction, KeymapStore}, mode::Mode, @@ -1103,24 +1104,9 @@ impl Render for SettingsWindow { let navigation: Vec<_> = SettingsPage::ALL .into_iter() .map(|page| { - div() - .id(format!("settings-page-{}", page.slug())) - .role(Role::Tab) + selection_row(format!("settings-page-{}", page.slug()), page == selected) + .aria_role(Role::Tab) .aria_label(page.title()) - .aria_selected(page == selected) - .mx_1() - .px_2() - .py_1() - .rounded_sm() - .cursor_pointer() - .when(page == selected, |row| { - row.bg(cx.theme().colors().element_selected) - .text_color(cx.theme().colors().text) - }) - .when(page != selected, |row| { - row.text_color(cx.theme().colors().text_muted) - .hover(|row| row.bg(cx.theme().colors().element_hover)) - }) .on_click(cx.listener(move |this, _, _, cx| { this.page = page; if page != SettingsPage::Plugins { @@ -1128,7 +1114,11 @@ impl Render for SettingsWindow { } cx.notify(); })) - .child(Label::new(page.title()).size(UI_LABEL_DEFAULT)) + .child( + Label::new(page.title()) + .size(UI_LABEL_DEFAULT) + .when(page != selected, |label| label.color(Color::Muted)), + ) }) .collect(); let unreadable = cx.global::().unreadable().map(str::to_owned); @@ -1171,7 +1161,7 @@ impl Render for SettingsWindow { .child(div().px_3().py_1().child( Label::new("Options").size(UI_LABEL_SMALL).color(Color::Muted), )) - .children(navigation), + .child(selection_list().px_1().children(navigation)), ) .child( div() diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 4ae99421..1f451966 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -395,34 +395,6 @@ impl Space { } } - /// Zed's split-and-move action creates the neighboring pane and moves the - /// active item into it. Items remain unique; terminals are never cloned. - pub fn split_and_move(&mut self, direction: SplitDirection) { - let Some(tab) = self.layout.active_tab_id() else { - return; - }; - let source = self.layout.workspace(tab).expect("active tab").active_pane(); - self.split_and_move_in(tab, source, direction); - } - - pub fn split_and_move_in( - &mut self, - tab: WorkspaceTabId, - source: crate::workspace::PaneId, - direction: SplitDirection, - ) { - let result = self - .layout - .workspace_mut(tab) - .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) - .and_then(|layout| layout.split_and_move(source, direction)); - if let Err(error) = result { - self.problem = Some(error.to_string()); - } else { - let _ = self.layout.activate_tab(tab); - } - } - pub fn remove_empty_pane(&mut self, tab: WorkspaceTabId, pane: crate::workspace::PaneId) { let result = self .layout @@ -464,27 +436,6 @@ impl Space { } } - pub fn toggle_zoom(&mut self) { - let Some(tab) = self.layout.active_tab_id() else { - return; - }; - let active = self.layout.workspace(tab).expect("active tab").active_pane(); - self.toggle_zoom_in(tab, active); - } - - pub fn toggle_zoom_in(&mut self, tab: WorkspaceTabId, pane: crate::workspace::PaneId) { - let result = self - .layout - .workspace_mut(tab) - .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) - .and_then(|layout| layout.center.toggle_maximized(pane)); - if let Err(error) = result { - self.problem = Some(error.to_string()); - } else { - let _ = self.layout.activate_tab(tab); - } - } - pub fn entries(&self, space: gpui::EntityId) -> Vec { self.layout .tabs() diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index 0f7210eb..f25e3dce 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -67,15 +67,6 @@ impl SplitDirection { pub fn increasing(self) -> bool { matches!(self, Self::Down | Self::Right) } - - pub fn opposite(self) -> Self { - match self { - Self::Up => Self::Down, - Self::Down => Self::Up, - Self::Left => Self::Right, - Self::Right => Self::Left, - } - } } /// One leaf or split axis in a pane tree. @@ -266,12 +257,11 @@ impl PaneAxis { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneGroup { pub root: Member, - pub maximized: Option, } impl PaneGroup { pub fn new(root: PaneId) -> Self { - Self { root: Member::pane(root), maximized: None } + Self { root: Member::pane(root) } } pub fn panes(&self) -> Vec { @@ -342,9 +332,6 @@ impl PaneGroup { if let Some(replacement) = axis.remove(pane)? { self.root = replacement; } - if self.maximized == Some(pane) { - self.maximized = None; - } Ok(true) } } @@ -398,14 +385,6 @@ impl PaneGroup { axis.flexes[divider + 1] = pair - left; Ok(()) } - - pub fn toggle_maximized(&mut self, pane: PaneId) -> Result<(), ModelError> { - if !self.contains(pane) { - return Err(ModelError::PaneNotFound(pane)); - } - self.maximized = (self.maximized != Some(pane)).then_some(pane); - Ok(()) - } } /// Ordered items and activation history for one pane. @@ -700,28 +679,6 @@ impl Workspace { Ok(new) } - /// Zed's `SplitMode::MovePane` behavior. Moving the sole tab would leave - /// its source empty and make ordinary empty-pane cleanup collapse the split - /// immediately. Zed instead inserts an empty pane on the opposite side and - /// keeps the sole tab focused, producing the same requested visual result. - pub fn split_and_move( - &mut self, - source: PaneId, - direction: SplitDirection, - ) -> Result { - let pane = self.panes.get(&source).ok_or(ModelError::PaneNotFound(source))?; - if pane.items.len() <= 1 { - let empty = self.split_pane(source, direction.opposite())?; - self.active_pane = source; - return Ok(empty); - } - - let active = pane.active.expect("a pane with multiple items always has an active item"); - let destination = self.split_pane(source, direction)?; - self.move_item(active, destination, None)?; - Ok(destination) - } - /// Join `source` into `destination`, moving every item in order and then /// collapsing the recursive group. pub fn join_pane(&mut self, source: PaneId, destination: PaneId) -> Result<(), ModelError> { @@ -1260,58 +1217,6 @@ mod tests { restored.validate().unwrap(); } - #[test] - fn splitting_a_lone_tab_with_two_existing_panes_matches_zeds_empty_pane_rule() { - for direction in - [SplitDirection::Up, SplitDirection::Down, SplitDirection::Left, SplitDirection::Right] - { - let mut workspace = Workspace::new(); - let first = workspace.active_pane(); - let second = workspace.split_pane(first, SplitDirection::Right).unwrap(); - let first_item = workspace.alloc_item(); - let second_item = workspace.alloc_item(); - workspace.add_item(first_item, Some(first), None).unwrap(); - workspace.add_item(second_item, Some(second), None).unwrap(); - - let empty = workspace.split_and_move(first, direction).unwrap(); - - let expected_order = if direction.increasing() { - vec![empty, first, second] - } else { - vec![first, empty, second] - }; - assert_eq!(workspace.center.panes(), expected_order, "{direction:?}"); - assert_eq!(workspace.pane(first).unwrap().items(), &[first_item]); - assert_eq!(workspace.pane(second).unwrap().items(), &[second_item]); - assert!(workspace.pane(empty).unwrap().items().is_empty()); - assert_eq!(workspace.active_pane(), first); - workspace.validate().unwrap(); - } - } - - #[test] - fn split_and_move_uses_the_explicit_source_when_another_pane_is_active() { - let mut workspace = Workspace::new(); - let first = workspace.active_pane(); - let second = workspace.split_pane(first, SplitDirection::Right).unwrap(); - let first_a = workspace.alloc_item(); - let first_b = workspace.alloc_item(); - let second_item = workspace.alloc_item(); - workspace.add_item(first_a, Some(first), None).unwrap(); - workspace.add_item(first_b, Some(first), None).unwrap(); - workspace.add_item(second_item, Some(second), None).unwrap(); - assert_eq!(workspace.active_pane(), second); - - let split = workspace.split_and_move(first, SplitDirection::Right).unwrap(); - - assert_eq!(workspace.center.panes(), vec![first, split, second]); - assert_eq!(workspace.pane(first).unwrap().items(), &[first_a]); - assert_eq!(workspace.pane(split).unwrap().items(), &[first_b]); - assert_eq!(workspace.pane(second).unwrap().items(), &[second_item]); - assert_eq!(workspace.active_pane(), split); - workspace.validate().unwrap(); - } - #[test] fn moving_the_last_item_removes_its_empty_source_pane_like_zed() { let mut workspace = Workspace::new(); @@ -1472,7 +1377,6 @@ mod tests { let item = workspace.alloc_item(); workspace.add_item(item, Some(down), None).unwrap(); workspace.center.set_flexes(&[], vec![1.5, 0.5]).unwrap(); - workspace.center.toggle_maximized(down).unwrap(); let encoded = serde_json::to_string(&workspace).unwrap(); let restored: Workspace = serde_json::from_str(&encoded).unwrap(); diff --git a/docs/acceptance.md b/docs/acceptance.md index 4528b620..d0d22804 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -26,7 +26,7 @@ Chartr Light. Capture and compare: - empty Free sessions startup, one folder, and several spaces; - Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; - empty space, one standalone tab, nested horizontal/vertical panes, resized dividers, - zoom, and automatic split collapse after its last item moves or closes; + and automatic split collapse after its last item moves or closes; - terminal and plugin close buttons, active/hover/focus states, two standalone outer tabs beside one collapsed three-item pane group in both chromes, visible draggable tab bars in every selected group pane, and bulk confirmation scoped @@ -51,12 +51,9 @@ tier. ## Interaction and accessibility Run the matrix with pointer and keyboard. Confirm `Cmd/Ctrl+W`, command palette, -directional focus, split-and-move, move-to-existing-pane, join, zoom, Settings -singleton focus, native `Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. -Close the last workspace and confirm Settings closes too. Every drag outcome -must have a semantic action alternative. With two panes already open, invoke all -four split directions from the first lone-tab pane and confirm each creates the -expected adjacent empty drop target without moving, losing focus, or collapsing. +directional focus, move-to-existing-pane, join, Settings singleton focus, native +`Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. Close the last workspace +and confirm Settings closes too. For tab dragging, exercise each pane-body center and edge target, both corner choices, before and after insertion on existing tabs, trailing-strip append, From 07352fb0009ffba314abdeb71af7306a02e84056 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 16:16:51 +0800 Subject: [PATCH 011/110] Refine shared list row spacing --- crates/zeddy/src/components.rs | 117 +++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/crates/zeddy/src/components.rs b/crates/zeddy/src/components.rs index 165d5c50..7af3d913 100644 --- a/crates/zeddy/src/components.rs +++ b/crates/zeddy/src/components.rs @@ -3,8 +3,11 @@ //! Content and behavior stay with their owning feature; only visual contracts //! shared across features belong here. -use gpui::{Div, ElementId}; -use ui::{ListItem, ListItemSpacing, prelude::*}; +use gpui::{ + AnyElement, App, ClickEvent, Div, ElementId, IntoElement, ParentElement, RenderOnce, Role, + SharedString, Window, px, relative, +}; +use ui::{DynamicSpacing, prelude::*}; /// A vertical collection of selectable rows. The inter-row gap is part of the /// collection rather than any individual row, so adjacent state backgrounds @@ -13,8 +16,110 @@ pub fn selection_list() -> Div { v_flex().gap_px() } -/// Chartr's common selectable-row treatment, backed by Zed's `ListItem` so -/// padding, corners, and interaction-state colors follow the component theme. -pub fn selection_row(id: impl Into, selected: bool) -> ListItem { - ListItem::new(id).spacing(ListItemSpacing::Sparse).rounded().toggle_state(selected) +/// Chartr's common selectable-row treatment. This mirrors Zed's sparse +/// `ListItem`, with one pixel removed from each vertical side. Zed only exposes +/// dense and sparse presets, so keeping the intermediate density here ensures +/// every Chartr list uses the same geometry and full-row hit target. +pub fn selection_row(id: impl Into, selected: bool) -> SelectionRow { + SelectionRow::new(id, selected) +} + +#[derive(IntoElement)] +pub struct SelectionRow { + id: ElementId, + selected: bool, + aria_role: Option, + aria_label: Option, + on_click: Option>, + start_slot: Option, + end_slot: Option, + children: Vec, +} + +impl SelectionRow { + fn new(id: impl Into, selected: bool) -> Self { + Self { + id: id.into(), + selected, + aria_role: None, + aria_label: None, + on_click: None, + start_slot: None, + end_slot: None, + children: Vec::new(), + } + } + + pub fn aria_role(mut self, role: Role) -> Self { + self.aria_role = Some(role); + self + } + + pub fn aria_label(mut self, label: impl Into) -> Self { + self.aria_label = Some(label.into()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } + + pub fn start_slot(mut self, slot: impl Into>) -> Self { + self.start_slot = slot.into().map(IntoElement::into_any_element); + self + } + + pub fn end_slot(mut self, slot: impl Into>) -> Self { + self.end_slot = slot.into().map(IntoElement::into_any_element); + self + } +} + +impl ParentElement for SelectionRow { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for SelectionRow { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let sparse_padding = window.rem_size() * 0.25; + let vertical_padding = + if sparse_padding > px(1.) { sparse_padding - px(1.) } else { px(0.) }; + let has_end_slot = self.end_slot.is_some(); + + h_flex() + .id(self.id) + .group("list_item") + .w_full() + .relative() + .gap_1() + .px(DynamicSpacing::Base06.rems(cx)) + .py(vertical_padding) + .rounded_sm() + .when_some(self.aria_role, |row, role| row.role(role).aria_selected(self.selected)) + .when_some(self.aria_label, |row, label| row.aria_label(label)) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.selected, |row| row.bg(cx.theme().colors().ghost_element_selected)) + .when_some(self.on_click, |row, on_click| row.cursor_pointer().on_click(on_click)) + .child( + h_flex() + .flex_grow_1() + .flex_shrink_0() + .flex_basis(relative(0.25)) + .gap(DynamicSpacing::Base06.rems(cx)) + .overflow_hidden() + .children(self.start_slot) + .children(self.children), + ) + .when(has_end_slot, |row| row.justify_between()) + .when_some(self.end_slot, |row, end_slot| { + row.child(h_flex().flex_shrink_1().overflow_hidden().child(end_slot)) + }) + } } From 73fee05923124925dd4ec368c5b85638f46e70ee Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 16:19:02 +0800 Subject: [PATCH 012/110] Fix dragged tab preview positioning --- crates/zeddy/src/chrome.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index f0dbac26..3cd54cdd 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -152,9 +152,8 @@ impl Render for DraggedItemPreview { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let width = dragged_item_pill_width(self.dragged.grouped); div() - .relative() - .left(self.source_offset.x - px(width / 2.)) - .top(self.source_offset.y - px(DRAGGED_ITEM_PILL_HEIGHT / 2.)) + .pl(self.source_offset.x - px(width / 2.)) + .pt(self.source_offset.y - px(DRAGGED_ITEM_PILL_HEIGHT / 2.)) .child(dragged_item_pill(self.dragged.grouped, cx)) } } From 408e176a1fed1e5b7f90758c611e6b728cdf83fc Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 19:08:41 +0800 Subject: [PATCH 013/110] Refine themed sidebar and compact tab picker --- crates/zeddy/src/chrome/sidebar.rs | 63 ++++++++++++++----- crates/zeddy/src/chrome/tabs.rs | 4 +- crates/zeddy/src/components.rs | 33 ++++++++-- crates/zeddy/src/settings.rs | 98 +++++++++++++++++++++++++++++- 4 files changed, 176 insertions(+), 22 deletions(-) diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 33b77a81..5f9427d7 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -4,7 +4,7 @@ //! tab cannot hold — the agent's name under the title, and a close button that //! is not fighting the title for space — so this chrome shows them. -use gpui::{Anchor, MouseButton, Role, deferred}; +use gpui::{Anchor, MouseButton, Role, deferred, transparent_black}; use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use super::Emit; @@ -13,13 +13,11 @@ use super::{ Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, status_indicator, }; -use crate::components::{selection_list, selection_row}; +use crate::components::{SelectionRowBackgrounds, selection_list, selection_row}; use crate::fonts::{UI_LABEL_DEFAULT, UI_LABEL_SMALL}; +use crate::settings::sidebar_theme_colors; -/// The sidebar's width. Fixed rather than draggable: a resizable sidebar is a -/// preference to persist, a drag handle to hit-test, and a minimum to enforce, -/// and none of that is what makes this mode useful. -pub const DEFAULT_WIDTH: f32 = 280.; +/// Limits for the resizable sidebar. pub const MIN_WIDTH: f32 = 180.; pub const MAX_WIDTH: f32 = 480.; @@ -31,20 +29,26 @@ pub fn render( cx: &App, ) -> impl IntoElement { let colors = cx.theme().colors(); - let mut groups = Vec::new(); + let sidebar_colors = sidebar_theme_colors(cx.theme()); + let session_backgrounds = SelectionRowBackgrounds { + hover: sidebar_colors.session_hover, + selected: sidebar_colors.session_active, + }; + let mut cards = Vec::with_capacity(spaces.len()); let mut index = 0; for (space_index, space) in spaces.iter().enumerate() { + let mut contents = Vec::with_capacity(space.entries.len() + 1); let add = on.clone(); let actions = on.clone(); let space_id = space.id; let action_space = space.id; let removable = space.removable; let available = space.available; - groups.push( + contents.push( h_flex() .group("space-heading") - .px_2() - .pt_2() + .pl_1() + .pt_0() .pb_1() .justify_between() .child(Label::new(space.name.clone()).size(UI_LABEL_SMALL).color(Color::Muted)) @@ -127,13 +131,14 @@ pub fn render( .into_any_element(), ); for (target_index, entry) in space.entries.iter().enumerate() { - groups.push( + contents.push( row( index, target_index, entry, space.active && entry.selected, entry.grouped, + session_backgrounds, on.clone(), cx, ) @@ -141,6 +146,33 @@ pub fn render( ); index += 1; } + + // A space and its sessions are one object in the sidebar. Keep the + // plate restrained so it separates neighbouring spaces without + // turning every session into a nested card; the stronger row fill is + // then free to keep meaning "selected session". A transparent resting + // border reserves the active-space ring without changing geometry. + cards.push( + selection_list() + .id(("space-card", space_index)) + .w_full() + .flex_none() + .p_1() + .rounded_md() + .border_1() + .border_color(if space.active { + colors.border_selected + } else { + transparent_black() + }) + .bg(if space.active { + sidebar_colors.card_active + } else { + sidebar_colors.card_inactive + }) + .children(contents) + .into_any_element(), + ); } v_flex() @@ -154,13 +186,14 @@ pub fn render( .border_color(colors.border) .child(header(space_switcher, on.clone())) .child( - selection_list() + v_flex() .id("sessions") .flex_1() .overflow_y_scroll() .py_1() - .px_1() - .children(groups), + .px_1p5() + .gap_2() + .children(cards), ) .child(deferred( div() @@ -205,6 +238,7 @@ fn row( entry: &Entry, selected: bool, grouped: bool, + backgrounds: SelectionRowBackgrounds, on: Emit, cx: &App, ) -> impl IntoElement { @@ -297,6 +331,7 @@ fn row( }) .child( selection_row(("session", index), selected) + .backgrounds(backgrounds) .aria_role(Role::Tab) .aria_label(if grouped { format!("Pane group: {}", entry.title) diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 864fc1e3..d64b11f9 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -13,6 +13,8 @@ use super::Emit; use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; use crate::fonts::UI_LABEL_DEFAULT; +const SPACE_SWITCHER_MAX_WIDTH: f32 = 200.; + pub fn render( entries: &[Entry], space_switcher: AnyElement, @@ -33,9 +35,9 @@ pub fn render( .border_color(colors.border) .child( h_flex() - .w(px(super::sidebar::DEFAULT_WIDTH)) .h_full() .flex_none() + .max_w(px(SPACE_SWITCHER_MAX_WIDTH)) .px_2() .border_r_1() .border_color(colors.border) diff --git a/crates/zeddy/src/components.rs b/crates/zeddy/src/components.rs index 7af3d913..56037a7b 100644 --- a/crates/zeddy/src/components.rs +++ b/crates/zeddy/src/components.rs @@ -4,8 +4,8 @@ //! shared across features belong here. use gpui::{ - AnyElement, App, ClickEvent, Div, ElementId, IntoElement, ParentElement, RenderOnce, Role, - SharedString, Window, px, relative, + AnyElement, App, ClickEvent, Div, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, + Role, SharedString, Window, px, relative, }; use ui::{DynamicSpacing, prelude::*}; @@ -24,6 +24,13 @@ pub fn selection_row(id: impl Into, selected: bool) -> SelectionRow { SelectionRow::new(id, selected) } +/// Optional state surfaces for a selection row embedded on a custom ground. +#[derive(Debug, Clone, Copy)] +pub struct SelectionRowBackgrounds { + pub hover: Hsla, + pub selected: Hsla, +} + #[derive(IntoElement)] pub struct SelectionRow { id: ElementId, @@ -33,6 +40,7 @@ pub struct SelectionRow { on_click: Option>, start_slot: Option, end_slot: Option, + backgrounds: Option, children: Vec, } @@ -46,6 +54,7 @@ impl SelectionRow { on_click: None, start_slot: None, end_slot: None, + backgrounds: None, children: Vec::new(), } } @@ -77,6 +86,11 @@ impl SelectionRow { self.end_slot = slot.into().map(IntoElement::into_any_element); self } + + pub fn backgrounds(mut self, backgrounds: SelectionRowBackgrounds) -> Self { + self.backgrounds = Some(backgrounds); + self + } } impl ParentElement for SelectionRow { @@ -91,6 +105,15 @@ impl RenderOnce for SelectionRow { let vertical_padding = if sparse_padding > px(1.) { sparse_padding - px(1.) } else { px(0.) }; let has_end_slot = self.end_slot.is_some(); + let colors = cx.theme().colors(); + let (selected_background, hover_background, active_background) = if let Some(backgrounds) = + self.backgrounds + { + let interaction = if self.selected { backgrounds.selected } else { backgrounds.hover }; + (backgrounds.selected, interaction, interaction) + } else { + (colors.ghost_element_selected, colors.ghost_element_hover, colors.ghost_element_active) + }; h_flex() .id(self.id) @@ -103,9 +126,9 @@ impl RenderOnce for SelectionRow { .rounded_sm() .when_some(self.aria_role, |row, role| row.role(role).aria_selected(self.selected)) .when_some(self.aria_label, |row, label| row.aria_label(label)) - .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .when(self.selected, |row| row.bg(cx.theme().colors().ghost_element_selected)) + .when(self.selected, |row| row.bg(selected_background)) + .hover(|style| style.bg(hover_background)) + .active(|style| style.bg(active_background)) .when_some(self.on_click, |row, on_click| row.cursor_pointer().on_click(on_click)) .child( h_flex() diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index b2d66761..de55d21a 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -12,7 +12,7 @@ use std::{ path::{Path, PathBuf}, }; -use gpui::BorrowAppContext; +use gpui::{BorrowAppContext, Hsla}; use serde::{Deserialize, Serialize}; use theme::{Appearance, GlobalTheme, SystemAppearance, Theme, ThemeRegistry}; @@ -562,7 +562,7 @@ const THEME_PALETTES: [ThemePalette; 13] = [ 0x5f5650, 0xecddb4, 0xc8b899, - 0xadc5cc, + 0xab9965, 0xc8b899, 0xddcca7, 0x9d0308, @@ -654,6 +654,85 @@ const THEME_PALETTES: [ThemePalette; 13] = [ ), ]; +/// Sidebar-only colors whose layering is too specific to borrow safely from +/// Zed's general element tokens. These values are deliberately explicit: this +/// table is the one hand-tuning point for every theme Chartr exposes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SidebarThemeColors { + pub card_inactive: Hsla, + pub card_active: Hsla, + pub session_hover: Hsla, + pub session_active: Hsla, +} + +#[derive(Clone, Copy)] +struct SidebarThemePalette { + name: &'static str, + card_inactive: u32, + card_active: u32, + session_hover: u32, + session_active: u32, +} + +impl SidebarThemePalette { + const fn new( + name: &'static str, + card_inactive: u32, + card_active: u32, + session_hover: u32, + session_active: u32, + ) -> Self { + Self { name, card_inactive, card_active, session_hover, session_active } + } + + fn colors(self) -> SidebarThemeColors { + let color = |value| gpui::rgb(value).into(); + SidebarThemeColors { + card_inactive: color(self.card_inactive), + card_active: color(self.card_active), + session_hover: color(self.session_hover), + session_active: color(self.session_active), + } + } +} + +// card card session session +// Theme inactive active hover active +const SIDEBAR_THEME_PALETTES: [SidebarThemePalette; 15] = [ + SidebarThemePalette::new("Ayu Dark", 0x23252a, 0x26282e, 0x27292f, 0x2d2f34), + SidebarThemePalette::new("Ayu Light", 0xe9e9ea, 0xe6e6e7, 0xe4e5e6, 0xdfe0e1), + SidebarThemePalette::new("Ayu Mirage", 0x393c47, 0x3c404a, 0x3d414b, 0x43464f), + SidebarThemePalette::new("Catppuccin Frappé", 0x2f3243, 0x35394b, 0x373b4d, 0x414559), + SidebarThemePalette::new("Catppuccin Latte", 0xe0e3ea, 0xd9dde5, 0xd6dae2, 0xccd0da), + SidebarThemePalette::new("Catppuccin Macchiato", 0x242738, 0x2a2d40, 0x2c3043, 0x363a4f), + SidebarThemePalette::new("Catppuccin Mocha", 0x1e1f2d, 0x252535, 0x272838, 0x313244), + SidebarThemePalette::new("Gruvbox Dark", 0x3e3a38, 0x423d3b, 0x433e3c, 0x494340), + SidebarThemePalette::new("Gruvbox Light", 0xF0E6C9, 0xF0E6C9, 0xe3d3ac, 0xddcca7), + SidebarThemePalette::new("One Dark", 0x313640, 0x333842, 0x333943, 0x363c46), + SidebarThemePalette::new("One Light", 0xe8e8e9, 0xe5e5e6, 0xe4e4e5, 0xdfdfe0), + SidebarThemePalette::new("VSCode Dark Modern", 0x1d1d1d, 0x222222, 0x232323, 0x2b2b2b), + SidebarThemePalette::new("VSCode Dark Plus", 0x262728, 0x28292a, 0x282a2b, 0x2a2d2e), + SidebarThemePalette::new(CHARTR_DARK, 0x313640, 0x333842, 0x333943, 0x363c46), + SidebarThemePalette::new(CHARTR_LIGHT, 0xf9fafb, 0xf4f5f7, 0xf1f3f5, 0xe8ebef), +]; + +pub fn sidebar_theme_colors(theme: &Theme) -> SidebarThemeColors { + SIDEBAR_THEME_PALETTES + .iter() + .find(|palette| palette.name == theme.name.as_ref()) + .copied() + .map(SidebarThemePalette::colors) + .unwrap_or_else(|| { + let colors = &theme.styles.colors; + SidebarThemeColors { + card_inactive: colors.element_background, + card_active: colors.element_active, + session_hover: colors.ghost_element_hover, + session_active: colors.ghost_element_selected, + } + }) +} + impl ThemePalette { #[allow(clippy::too_many_arguments)] const fn new( @@ -908,6 +987,21 @@ mod tests { palette.name, ); } + for palette in SIDEBAR_THEME_PALETTES { + let registered = registry.get(palette.name).unwrap(); + let colors = sidebar_theme_colors(®istered); + assert_eq!(colors, palette.colors()); + assert_ne!( + colors.card_inactive, colors.card_active, + "{} needs distinct inactive and active cards", + palette.name, + ); + assert_ne!( + colors.card_active, colors.session_active, + "{} needs a visible selected session inside an active card", + palette.name, + ); + } assert_eq!(registry.get(CHARTR_DARK).unwrap().appearance, Appearance::Dark); assert_eq!(registry.get(CHARTR_LIGHT).unwrap().appearance, Appearance::Light); }); From b9108f4ab619b3c29eb5112dfe8e49ec74ad2e4d Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 19:22:17 +0800 Subject: [PATCH 014/110] Use Zed TabBar for workspace tabs --- crates/zeddy/src/chrome/tabs.rs | 39 ++++++++++----------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index d64b11f9..b24412f4 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -6,7 +6,7 @@ //! identity. use gpui::Role; -use ui::{ButtonSize, IconButtonShape, Tab, TabPosition, Tooltip, prelude::*}; +use ui::{ButtonSize, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*}; use super::Emit; @@ -22,39 +22,22 @@ pub fn render( on: Emit, cx: &App, ) -> impl IntoElement { - let colors = cx.theme().colors(); let settings = on.clone(); let active_index = entries.iter().position(|entry| entry.selected); - h_flex() - .h(Tab::container_height(cx)) - .flex_none() - .w_full() - .bg(colors.tab_bar_background) - .border_b_1() - .border_color(colors.border) - .child( - h_flex() - .h_full() - .flex_none() - .max_w(px(SPACE_SWITCHER_MAX_WIDTH)) - .px_2() - .border_r_1() - .border_color(colors.border) - .child(space_switcher), - ) - .child(h_flex().id("tabs").flex_1().overflow_x_scroll().children( + TabBar::new("workspace-tabs") + .start_child(h_flex().flex_none().max_w(px(SPACE_SWITCHER_MAX_WIDTH)).child(space_switcher)) + .children( entries.iter().enumerate().map(|(index, entry)| { tab(index, entries.len(), active_index, entry, on.clone(), cx) }), - )) - .child( - h_flex().px_1().gap_px().flex_none().child(new_item).child( - IconButton::new("open-settings", IconName::Settings) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Settings")) - .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), - ), + ) + .end_child(new_item) + .end_child( + IconButton::new("open-settings", IconName::Settings) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Settings")) + .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), ) } From 2e6e843159739a88486892e66f4e11fccfb104c3 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 21:33:21 +0800 Subject: [PATCH 015/110] feat: make sidebar spaces drag-sortable --- .plan/maps/chartr-zeddy-workspace/spec.md | 22 +- README.md | 17 +- crates/zeddy/src/app.rs | 144 +++- crates/zeddy/src/chrome.rs | 20 +- crates/zeddy/src/chrome/sidebar.rs | 649 +++++++++++++++++- crates/zeddy/src/main.rs | 1 + crates/zeddy/src/settings.rs | 10 + crates/zeddy/src/settings_window.rs | 34 + crates/zeddy/src/space.rs | 1 + crates/zeddy/src/spaces.rs | 105 ++- docs/acceptance.md | 22 +- .../0005-spaces-follow-zed-multi-workspace.md | 19 +- 12 files changed, 1005 insertions(+), 39 deletions(-) diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index d9c6a9ee..2542c588 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -45,6 +45,12 @@ local draggable pane tab bars, while selecting a standalone item renders no redundant inner bar. Only the active pane exposes compact split/zoom controls. Presentation never changes item ownership. +In the all-spaces sidebar, space headings directly sort their complete cards. +Sorting uses measured variable-height midpoints, remains active during horizontal +overdrag, resolves the final slot from release Y, and autoscrolls at the vertical +edges. A short interruptible FLIP transition settles displaced cards unless the +user enables Reduce Motion. + Use Zed's existing GPUI, UI, and theme crates and their components, semantic colors, spacing, typography, focus, accessibility, menu, modal, notification, and drag-and-drop conventions. Chartr owns product composition, not replacement @@ -158,6 +164,8 @@ configuration automatically. 88. As a Chartr user, I want a fresh installation to open the empty Ad-hoc space without spawning a terminal, so that startup has no unnecessary process side effect. 89. As a Chartr user, I want window geometry, pane ratios, expansion state, selection, and chrome restored, so that the entire cockpit returns after relaunch. 90. As an existing Chartr user, I want Chartr-zeddy data isolated from older installations, so that the rewrite cannot corrupt or conflict with existing settings. +91. As a Chartr user, I want to drag-sort every sidebar space, including Free sessions and recovered folders, so that the cockpit order matches my workflow and survives relaunch. +92. As an accessibility user, I want Reduce Motion to disable space-sort settling without disabling direct manipulation, so that reordering remains usable with less animation. ## Implementation Decisions @@ -228,6 +236,11 @@ configuration automatically. - Sidebar mode persists an All Spaces or Active Space submode. Selecting an item from another space activates its space, pane, and item as one operation. - The sidebar is resizable with bounded width. Tabbed mode is active-space-only. + All-Spaces card sorting is a window-owned, space-specific interaction: the + complete card carries only on Y, live order changes at measured card + midpoints, tracked-scroll edge autoscroll follows Zed's curve, and release + outside the sidebar resolves the closest legal Y slot. Displaced cards use an + interruptible fixed 150 ms quintic FLIP unless Reduce Motion is enabled. - User-visible actions are semantic GPUI actions with contextual keybindings. Platform defaults follow Zed except that terminal focus does not override the requested `Cmd/Ctrl+W` close behavior. @@ -292,7 +305,7 @@ configuration automatically. newly empty splits and outer tabs, and retains spaces plus space-bound plugins. - Herdr is authoritative for live session existence. Orphaned sessions enter the owning space as standalone outer tabs; stale saved terminal items are dropped. -- Versioned SQLite persistence stores space identities, ordered outer workspace +- Versioned SQLite persistence stores ordered space identities, ordered outer workspace tabs, pane trees, item records, active state, split ratios, window bounds, sidebar width/submode, chrome mode, expansion state, and migrations. A legacy single pane tree migrates to one outer workspace tab. @@ -321,7 +334,10 @@ configuration automatically. - Chrome tests assert that switching Tabbed, Sidebar/All Spaces, and Sidebar/Active Space changes only presentation; both chromes show the same standalone and grouped outer entries. Selecting and creating items from inactive groups must - activate the correct space, outer tab, and pane without duplication. + activate the correct space, outer tab, and pane without duplication. Sorter + tests cover variable-height midpoint order, final release Y, interruptible + FLIP, Reduce Motion, durable relaunch order, and registry-write rollback; + pointer acceptance covers horizontal overdrag and edge autoscroll. - Action tests use semantic commands and contexts, including close, Settings close, split, join, focus, move, zoom, palette dispatch, and keybinding conflicts. - Settings tests cover default resolution, sparse user content, atomic updates, @@ -366,6 +382,8 @@ configuration automatically. - A generic process-supervisor framework or backend administration UI. - Phosphor compatibility or user-selectable application-control icon sets. - Exposing non-default UI density before it has dedicated visual acceptance. +- A command, keybinding, or Hotkeys row for space sorting. +- A reusable generic sortable framework or user-configurable sort animation. ## Further Notes diff --git a/README.md b/README.md index 1d98d76a..726c5f2c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,10 @@ standalone item and every pane group as one outer entry. Sidebar mode can show all spaces or only the active space; tabbed mode keeps the active space's outer entries beside its name. Selecting a group reveals its Zed-style draggable pane-local tab bars, while a standalone has no duplicate inner bar. Switching -presentation never reparents or recreates an item. +presentation never reparents or recreates an item. In All Spaces, drag a space +heading to reorder its whole card. The card stays locked to the sidebar's X axis, +continues tracking vertically outside the sidebar, autoscrolls at the list edges, +and settles into the closest legal slot at release. Terminal titles follow Herdr's live view of the PTY, as in Chartr-rs: a detected agent wins, otherwise the non-shell foreground process is shown, and an idle @@ -67,7 +70,8 @@ Chartr Dark is the fixed default. Appearance exposes the same Ayu, Catppuccin, Gruvbox, One, and VS Code catalog as Chartr-rs, plus Chartr Dark and Chartr Light. Fixed mode chooses one theme; Match System keeps independent light and dark selections. IBM Plex Sans and the bundled IBM Plex Mono are configurable -defaults. +defaults. Reduce Motion disables the short space-sort settle animation while +retaining direct pointer tracking. User-editable data remains text: @@ -75,9 +79,12 @@ User-editable data remains text: - `$XDG_CONFIG_HOME/chartr-zeddy/keymap.toml` - `$XDG_CONFIG_HOME/chartr-zeddy/spaces.toml` -Application-owned window, chrome, pane, selection, and restorable-item state is -versioned SQLite under `$XDG_STATE_HOME/chartr-zeddy/state.sqlite`. No existing -Go Chartr or Chartr-rs configuration is imported automatically. +Application-owned window, chrome, full space order, pane, selection, and +restorable-item state is versioned SQLite under +`$XDG_STATE_HOME/chartr-zeddy/state.sqlite`. Registered folder order is also the +file order in `spaces.toml`, so a registry write failure rejects and rolls back +the drop. No existing Go Chartr or Chartr-rs configuration is imported +automatically. Normal app exit detaches sessions. An optional setting terminates them instead. The private Herdr runtime uses an exact socket under diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 09e1e4c8..f2be4d39 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -14,8 +14,8 @@ use std::{ }; use gpui::{ - Anchor, AnyView, DragMoveEvent, Entity, EntityId, FocusHandle, Focusable, PathPromptOptions, - Role, + Anchor, AnyView, DragMoveEvent, Entity, EntityId, FocusHandle, Focusable, MouseButton, + PathPromptOptions, Role, }; use ui::{ Banner, ButtonLike, ButtonSize, ContextMenu, IconButtonShape, IconPosition, ListItem, @@ -130,6 +130,7 @@ pub struct Zeddy { supervision_started: bool, registry: Option, spaces: Vec>, + space_sorter: chrome::sidebar::SpaceSorter, active: Option>, mode: Mode, catalog: Catalog, @@ -156,6 +157,7 @@ impl Zeddy { let settings = cx.global::().clone(); cx.observe_global::(|this, cx| { this.settings = cx.global::().clone(); + cx.set_reduce_motion(this.settings.resolved().reduce_motion); cx.notify(); }) .detach(); @@ -194,6 +196,7 @@ impl Zeddy { supervision_started: false, registry: None, spaces: Vec::new(), + space_sorter: chrome::sidebar::SpaceSorter::default(), active: None, mode: Mode::default(), catalog: Catalog::default(), @@ -269,6 +272,7 @@ impl Zeddy { space }) .collect(); + let spaces = restore_space_order(spaces, &saved.spaces, cx); for space in &spaces { let key = space.read(cx).persisted().key; if let Some(saved_space) = saved.spaces.iter().find(|saved| saved.key == key) { @@ -307,6 +311,7 @@ impl Zeddy { supervision_started: false, registry, spaces, + space_sorter: chrome::sidebar::SpaceSorter::default(), active, mode: saved.window.chrome, catalog, @@ -780,6 +785,7 @@ impl Zeddy { fn act(&mut self, action: Action, window: &mut Window, cx: &mut Context) { match action { + Action::BeginSpaceDrag { at } => self.space_sorter.press(at), Action::OpenSettings => self.open_settings(window, cx), Action::New => { if matches!(self.backend, Backend::Ready) @@ -846,6 +852,74 @@ impl Zeddy { cx.notify(); } + fn finish_space_drag( + &mut self, + pointer_y: gpui::Pixels, + window: &mut Window, + cx: &mut Context, + ) { + let now = cx.background_executor().now(); + let Some((space, target)) = + self.space_sorter.drop_at(pointer_y, window.rem_size(), now, cx.reduce_motion()) + else { + return; + }; + if self.commit_space_order(space, target, cx) { + self.space_sorter.accept_drop(now, cx.reduce_motion()); + } else { + // The registry is the durable folder-list authority. If it refuses + // the arrangement, the temporary drawn order was never a model + // change and disappears in one frame. + self.space_sorter.cancel(); + } + cx.notify(); + } + + fn commit_space_order(&mut self, space: EntityId, target: usize, cx: &App) -> bool { + let Some(from) = self.spaces.iter().position(|candidate| candidate.entity_id() == space) + else { + return false; + }; + let target = target.min(self.spaces.len().saturating_sub(1)); + if from == target { + return true; + } + + let mut candidate = self.spaces.clone(); + let moved = candidate.remove(from); + candidate.insert(target, moved); + + let Some(registry) = self.registry.as_ref() else { + self.problem = Some("the space registry is unavailable".into()); + return false; + }; + // The synthetic Free space has no registry row unless the operator had + // explicitly registered its directory. Recovered state-only folders + // likewise stay out. The remaining projection names every registry row + // exactly once and preserves its relative sidebar order. + let registered: Vec<_> = candidate + .iter() + .filter_map(|space| { + let path = space.read(cx).path(); + registry + .spaces() + .iter() + .any(|registered| spaces::same_path(registered.path(), path)) + .then(|| path.clone()) + }) + .collect(); + match self.registry.as_mut().expect("checked above").reorder(®istered) { + Ok(_) => { + self.spaces = candidate; + true + } + Err(error) => { + self.problem = Some(error.to_string()); + false + } + } + } + fn close_active_item(&mut self, cx: &mut Context) { if self.command_palette_open { self.command_palette_open = false; @@ -1415,6 +1489,7 @@ impl Zeddy { return; } if event.keystroke.key == "escape" && cx.stop_active_drag(window) { + self.space_sorter.cancel(); if let Some(space) = self.active.clone() { space.update(cx, |space, _| { space.clear_drag_target(); @@ -2707,7 +2782,12 @@ impl Render for Zeddy { self.restore_plugins_once(window, cx); self.persist_if_changed(cx); let entries = self.entries(cx); - let sidebar_spaces = self.sidebar_spaces(cx); + let now = cx.background_executor().now(); + if self.space_sorter.tick(now, window.rem_size(), cx.reduce_motion()) { + window.request_animation_frame(); + } + let mut sidebar_spaces = self.sidebar_spaces(cx); + self.space_sorter.arrange(&mut sidebar_spaces, |space| space.id); let chrome_entries: &[Entry] = &entries; let switcher = self.space_switcher(window, cx); let new_item = self.new_item_menu(cx); @@ -2734,6 +2814,7 @@ impl Render for Zeddy { &sidebar_spaces, switcher, emit.clone(), + &self.space_sorter, self.sidebar_width, cx, )) @@ -2771,6 +2852,34 @@ impl Render for Zeddy { cx.notify(); }, )) + .on_drag_move::(cx.listener( + |this, event: &DragMoveEvent, window, cx| { + let dragged = event.drag(cx).0; + let order = this.spaces.iter().map(|space| space.entity_id()).collect(); + if this.space_sorter.drag_move( + dragged, + order, + event.event.position, + window.rem_size(), + cx.background_executor().now(), + cx.reduce_motion(), + ) { + cx.notify(); + } + }, + )) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, event: &gpui::MouseUpEvent, window, cx| { + this.finish_space_drag(event.position.y, window, cx) + }), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, event: &gpui::MouseUpEvent, window, cx| { + this.finish_space_drag(event.position.y, window, cx) + }), + ) .on_action(cx.listener(|this, _: &actions::pane::CloseActiveItem, _, cx| { this.close_active_item(cx) })) @@ -2822,6 +2931,35 @@ impl Render for Zeddy { } } +/// Seats every space the current registry/state can recover in the last saved +/// full-vector order. Registry-only additions retain file order at the end; +/// an older snapshot that predates the synthetic Free entry gets that entry at +/// the front instead of unexpectedly moving it behind every recovered folder. +fn restore_space_order( + mut spaces: Vec>, + saved: &[crate::persistence::PersistedSpace], + cx: &App, +) -> Vec> { + if saved.is_empty() { + return spaces; + } + let mut ordered = Vec::with_capacity(spaces.len()); + for saved in saved { + let Some(index) = spaces.iter().position(|space| space.read(cx).key() == saved.key) else { + continue; + }; + ordered.push(spaces.remove(index)); + } + if !saved.iter().any(|space| space.kind == PersistedSpaceKind::AdHoc) + && let Some(index) = + spaces.iter().position(|space| space.read(cx).kind() == SpaceKind::AdHoc) + { + ordered.insert(0, spaces.remove(index)); + } + ordered.extend(spaces); + ordered +} + fn pane_drop_direction_for_drag( event: &DragMoveEvent, ) -> Option> { diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 3cd54cdd..3792fb0f 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -14,7 +14,7 @@ use crate::{ fonts::UI_LABEL_DEFAULT, workspace::{ItemId, PaneId, WorkspaceTabId}, }; -use gpui::EntityId; +use gpui::{EntityId, Pixels}; use ui::{CommonAnimationExt, prelude::*}; use zeddy_herdr::control::SessionStatus; @@ -53,12 +53,13 @@ pub struct SpaceEntries { } /// What the user did to the chrome. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum Action { Select { space: Option, item: ItemId }, Close { space: Option, item: ItemId }, CloseGroup { space: EntityId, tab: WorkspaceTabId }, MoveWorkspaceTab { space: EntityId, tab: WorkspaceTabId, target_index: usize }, + BeginSpaceDrag { at: Pixels }, CloseSpace { space: EntityId }, RenameSpace { space: EntityId }, LocateSpace { space: EntityId }, @@ -67,6 +68,21 @@ pub enum Action { OpenSettings, } +/// A whole sidebar space card in flight. +/// +/// Space sorting deliberately has its own payload type. Session rows nested in +/// the card continue to carry [`DraggedItem`], so GPUI dispatches the two drag +/// gestures to different listeners without either surface inspecting or +/// rejecting the other's values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DraggedSpace(pub EntityId); + +impl Render for DraggedSpace { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + /// How a chrome reports what the user did. /// /// `Rc` because both chromes hand the same callback to every row they draw, diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 5f9427d7..21cf56d3 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -4,13 +4,21 @@ //! tab cannot hold — the agent's name under the title, and a close button that //! is not fighting the title for space — so this chrome shows them. -use gpui::{Anchor, MouseButton, Role, deferred, transparent_black}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +use gpui::{ + Anchor, Bounds, EntityId, MouseButton, Pixels, Point, Rems, Role, ScrollHandle, deferred, + point, px, transparent_black, +}; use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; use super::Emit; use super::{ - Action, DraggedItem, DraggedSidebar, Entry, SpaceEntries, dragged_item_preview, + Action, DraggedItem, DraggedSidebar, DraggedSpace, Entry, SpaceEntries, dragged_item_preview, status_indicator, }; use crate::components::{SelectionRowBackgrounds, selection_list, selection_row}; @@ -21,10 +29,406 @@ use crate::settings::sidebar_theme_colors; pub const MIN_WIDTH: f32 = 180.; pub const MAX_WIDTH: f32 = 480.; +/// One shared value for layout and FLIP arithmetic. `gap_2` is half a rem; +/// spelling that out keeps card travel equal to the distance layout actually +/// moved it. +const CARD_GAP: Rems = Rems(0.5); +const FLIP_DURATION: Duration = Duration::from_millis(150); +const AUTOSCROLL_EDGE: Pixels = px(32.); + +fn flip_progress(elapsed: Duration) -> f32 { + let t = (elapsed.as_secs_f32() / FLIP_DURATION.as_secs_f32()).clamp(0., 1.); + 1. - (1. - t).powi(5) +} + +fn crossed_index( + item: EntityId, + order: &[EntityId], + geometry: &[(EntityId, Bounds)], + pointer: Pixels, +) -> Option { + let (first, last) = (geometry.first()?, geometry.last()?); + let target = if pointer < first.1.top() { + first.0 + } else if pointer > last.1.bottom() { + last.0 + } else { + geometry.iter().find(|(_, bounds)| pointer >= bounds.top() && pointer <= bounds.bottom())?.0 + }; + if target == item { + return None; + } + let from = order.iter().position(|id| *id == item)?; + let to = order.iter().position(|id| *id == target)?; + let bounds = geometry.iter().find(|(id, _)| *id == target)?.1; + let beyond = (to == 0 && pointer < bounds.top()) + || (to + 1 == geometry.len() && pointer > bounds.bottom()); + let midpoint = bounds.top() + bounds.size.height * 0.5; + (beyond || if to > from { pointer > midpoint } else { pointer < midpoint }).then_some(to) +} + +fn closest_index( + item: EntityId, + geometry: &[(EntityId, Bounds)], + pointer: Pixels, +) -> usize { + geometry + .iter() + .filter(|(id, bounds)| *id != item && pointer >= bounds.top() + bounds.size.height * 0.5) + .count() +} + +struct HeldSpace { + item: EntityId, + order: Vec, + anchor: Pixels, + pointer: Pixels, + slot_moved: Pixels, + scroll_y: Pixels, +} + +impl HeldSpace { + fn carried(&self) -> Pixels { + (self.pointer - self.anchor) - self.slot_moved + } +} + +struct Slide { + from: Pixels, + at: Instant, +} + +/// Window-only state for sorting the sidebar's variable-height space cards. +/// +/// The model order is not changed until release. While a drag is active this +/// owns the temporary id order, measured card heights, interruptible FLIP +/// offsets, and the tracked scroll geometry needed to keep sorting alive after +/// the pointer leaves the sidebar horizontally. +pub struct SpaceSorter { + sizes: HashMap, + slides: HashMap, + held: Option, + pressed_at: Option, + scroll: ScrollHandle, + last_tick: Option, +} + +impl Default for SpaceSorter { + fn default() -> Self { + Self { + sizes: HashMap::new(), + slides: HashMap::new(), + held: None, + pressed_at: None, + scroll: ScrollHandle::new(), + last_tick: None, + } + } +} + +impl SpaceSorter { + pub fn scroll_handle(&self) -> &ScrollHandle { + &self.scroll + } + + pub fn press(&mut self, at: Pixels) { + self.pressed_at = Some(at); + } + + pub fn holds(&self, item: EntityId) -> bool { + self.held.as_ref().is_some_and(|held| held.item == item) + } + + pub fn arrange(&self, items: &mut [T], id: impl Fn(&T) -> EntityId) { + let Some(held) = &self.held else { + return; + }; + items.sort_by_key(|item| { + let id = id(item); + held.order.iter().position(|known| *known == id).unwrap_or(usize::MAX) + }); + } + + pub fn offset_of(&self, item: EntityId, now: Instant, reduce_motion: bool) -> Pixels { + if let Some(held) = &self.held + && held.item == item + { + return held.carried(); + } + if reduce_motion { + return px(0.); + } + self.slides + .get(&item) + .map(|slide| slide.from * (1. - flip_progress(now.saturating_duration_since(slide.at)))) + .unwrap_or(px(0.)) + } + + pub fn drag_move( + &mut self, + dragged: EntityId, + model_order: Vec, + pointer: Point, + rem: Pixels, + now: Instant, + reduce_motion: bool, + ) -> bool { + if self.held.is_none() { + if !model_order.contains(&dragged) { + return false; + } + self.held = Some(HeldSpace { + item: dragged, + order: model_order, + anchor: self.pressed_at.take().unwrap_or(pointer.y), + pointer: pointer.y, + slot_moved: px(0.), + scroll_y: self.scroll.offset().y, + }); + self.last_tick = Some(now); + } + let Some(held) = self.held.as_mut() else { + return false; + }; + if held.item != dragged { + return false; + } + let changed = held.pointer != pointer.y; + held.pointer = pointer.y; + self.sync_scroll(); + self.cross_midpoint(rem, now, reduce_motion) || changed + } + + /// Advances both FLIP and edge autoscroll. Returns whether the window owes + /// the sorter another animation frame. + pub fn tick(&mut self, now: Instant, rem: Pixels, reduce_motion: bool) -> bool { + if reduce_motion { + self.slides.clear(); + } else { + self.slides.retain(|_, slide| now.saturating_duration_since(slide.at) < FLIP_DURATION); + } + + let mut scrolling = false; + if self.held.is_some() { + self.sync_scroll(); + scrolling = self.autoscroll(now); + if scrolling { + self.sync_scroll(); + self.cross_midpoint(rem, now, reduce_motion); + } + } else { + self.last_tick = None; + } + scrolling || !self.slides.is_empty() + } + + /// Resolves the release's final Y even if no last drag-move event reached + /// the sidebar, then returns the model move that should be committed. + pub fn drop_at( + &mut self, + pointer_y: Pixels, + rem: Pixels, + now: Instant, + reduce_motion: bool, + ) -> Option<(EntityId, usize)> { + let held = self.held.as_mut()?; + held.pointer = pointer_y; + self.sync_scroll(); + if let Some(to) = self.nearest_index(pointer_y) { + self.reorder_held(to, rem, now, reduce_motion); + } + let held = self.held.as_ref()?; + let to = held.order.iter().position(|id| *id == held.item)?; + Some((held.item, to)) + } + + pub fn accept_drop(&mut self, now: Instant, reduce_motion: bool) { + let Some(held) = self.held.take() else { + return; + }; + let carried = held.carried(); + if !reduce_motion && carried != px(0.) { + self.slides.insert(held.item, Slide { from: carried, at: now }); + } + self.pressed_at = None; + self.last_tick = None; + } + + pub fn cancel(&mut self) { + self.held = None; + self.pressed_at = None; + self.slides.clear(); + self.last_tick = None; + } + + fn sync_scroll(&mut self) { + let Some(held) = self.held.as_mut() else { + return; + }; + let scroll_y = self.scroll.offset().y; + if scroll_y != held.scroll_y { + held.slot_moved += scroll_y - held.scroll_y; + held.scroll_y = scroll_y; + } + } + + fn geometry(&mut self) -> Option)>> { + let order = self.held.as_ref()?.order.clone(); + if self.scroll.children_count() != order.len() { + return None; + } + let scroll_y = self.scroll.offset().y; + let mut geometry = Vec::with_capacity(order.len()); + for (index, id) in order.into_iter().enumerate() { + let mut bounds = self.scroll.bounds_for_item(index)?; + bounds.origin.y += scroll_y; + self.sizes.insert(id, bounds.size.height); + geometry.push((id, bounds)); + } + Some(geometry) + } + + fn cross_midpoint(&mut self, rem: Pixels, now: Instant, reduce_motion: bool) -> bool { + let Some(pointer) = self.held.as_ref().map(|held| held.pointer) else { + return false; + }; + let Some(geometry) = self.geometry() else { + return false; + }; + let Some(held) = self.held.as_ref() else { + return false; + }; + let Some(to) = crossed_index(held.item, &held.order, &geometry, pointer) else { + return false; + }; + self.reorder_held(to, rem, now, reduce_motion) + } + + fn nearest_index(&mut self, pointer: Pixels) -> Option { + let item = self.held.as_ref()?.item; + let geometry = self.geometry()?; + Some(closest_index(item, &geometry, pointer)) + } + + fn reorder_held(&mut self, to: usize, rem: Pixels, now: Instant, reduce_motion: bool) -> bool { + let Some(held) = self.held.as_mut() else { + return false; + }; + let Some(from) = held.order.iter().position(|id| *id == held.item) else { + return false; + }; + let to = to.min(held.order.len().saturating_sub(1)); + if from == to { + return false; + } + let before = held.order.clone(); + let item = held.order.remove(from); + held.order.insert(to, item); + let after = held.order.clone(); + self.start_slides(&before, &after, rem, now, reduce_motion); + true + } + + fn layout(&self, order: &[EntityId], gap: Pixels) -> Option> { + let mut y = px(0.); + let mut tops = HashMap::with_capacity(order.len()); + for item in order { + tops.insert(*item, y); + y += *self.sizes.get(item)? + gap; + } + Some(tops) + } + + fn start_slides( + &mut self, + before: &[EntityId], + after: &[EntityId], + rem: Pixels, + now: Instant, + reduce_motion: bool, + ) { + let gap = CARD_GAP.to_pixels(rem); + let (Some(was), Some(current)) = (self.layout(before, gap), self.layout(after, gap)) else { + return; + }; + if let Some(held) = self.held.as_mut() + && let (Some(was), Some(current)) = (was.get(&held.item), current.get(&held.item)) + { + held.slot_moved += *current - *was; + } + if reduce_motion { + self.slides.clear(); + return; + } + let column = after + .iter() + .filter_map(|item| Some(*current.get(item)? + *self.sizes.get(item)?)) + .fold(px(0.), Pixels::max) + .max(px(0.)); + for item in after { + if self.holds(*item) { + continue; + } + let (Some(was), Some(current)) = (was.get(item), current.get(item)) else { + continue; + }; + let from = + ((*was - *current) + self.offset_of(*item, now, false)).clamp(-column, column); + if from == px(0.) { + self.slides.remove(item); + } else { + self.slides.insert(*item, Slide { from, at: now }); + } + } + } + + fn autoscroll(&mut self, now: Instant) -> bool { + let Some(held) = self.held.as_ref() else { + return false; + }; + let viewport = self.scroll.bounds(); + if viewport.size.height <= px(0.) { + return false; + } + let edge = AUTOSCROLL_EDGE.min(viewport.size.height / 3.); + let top = viewport.top() + edge; + let bottom = viewport.bottom() - edge; + let pointer = held.pointer; + let direction = if pointer < top { + 1. + } else if pointer > bottom { + -1. + } else { + self.last_tick = Some(now); + return false; + }; + let distance = if direction > 0. { top - pointer } else { pointer - bottom }; + // Zed's editor uses the same capped nonlinear curve for selection + // autoscroll. Scale it by elapsed frames so speed is stable if a frame + // is delayed. + let speed: f32 = (distance.pow(1.2) / 100.).min(px(3.)).into(); + let elapsed = self + .last_tick + .replace(now) + .map(|last| now.saturating_duration_since(last).as_secs_f32()) + .unwrap_or_default(); + let frame_scale = (elapsed * 60.).clamp(0., 3.); + let offset = self.scroll.offset(); + let max = self.scroll.max_offset().y; + let next = (offset.y + px(direction * speed * frame_scale)).clamp(-max, px(0.)); + if next == offset.y { + return false; + } + self.scroll.set_offset(point(offset.x, next)); + true + } +} + pub fn render( spaces: &[SpaceEntries], space_switcher: AnyElement, on: Emit, + sorter: &SpaceSorter, width: f32, cx: &App, ) -> impl IntoElement { @@ -36,6 +440,8 @@ pub fn render( }; let mut cards = Vec::with_capacity(spaces.len()); let mut index = 0; + let now = cx.background_executor().now(); + let reduce_motion = cx.reduce_motion(); for (space_index, space) in spaces.iter().enumerate() { let mut contents = Vec::with_capacity(space.entries.len() + 1); let add = on.clone(); @@ -44,6 +450,9 @@ pub fn render( let action_space = space.id; let removable = space.removable; let available = space.available; + let space_drag = DraggedSpace(space.id); + let begin_drag = on.clone(); + let dragging = cx.has_active_drag(); contents.push( h_flex() .group("space-heading") @@ -51,7 +460,34 @@ pub fn render( .pt_0() .pb_1() .justify_between() - .child(Label::new(space.name.clone()).size(UI_LABEL_SMALL).color(Color::Muted)) + .child( + h_flex() + .id(("space-drag", space_index)) + .min_w_0() + .flex_1() + .child( + Label::new(space.name.clone()) + .size(UI_LABEL_SMALL) + .color(Color::Muted) + .truncate(), + ) + .when(spaces.len() > 1, |handle| { + handle + .when(!dragging, |handle| handle.cursor_grab()) + .when(dragging, |handle| handle.cursor_grabbing()) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + begin_drag( + Action::BeginSpaceDrag { at: event.position.y }, + window, + cx, + ) + }) + .on_drag(space_drag, |dragged, _, _, cx| { + let dragged = *dragged; + cx.new(move |_| dragged) + }) + }), + ) .child( h_flex() .gap_px() @@ -152,25 +588,36 @@ pub fn render( // turning every session into a nested card; the stronger row fill is // then free to keep meaning "selected session". A transparent resting // border reserves the active-space ring without changing geometry. + let held = sorter.holds(space.id); + let offset = sorter.offset_of(space.id, now, reduce_motion); + let card = selection_list() + .id(format!("space-card-{:?}", space.id)) + .relative() + .w_full() + .flex_none() + .p_1() + .rounded_md() + .border_1() + .border_color(if space.active { colors.border_selected } else { transparent_black() }) + .bg(if space.active { + sidebar_colors.card_active + } else { + sidebar_colors.card_inactive + }) + .when(held, |card| card.border_color(colors.drop_target_border).shadow_md()) + .when(offset != px(0.), |card| card.top(offset)) + .children(contents); cards.push( - selection_list() - .id(("space-card", space_index)) + div() + .id(format!("space-slot-{:?}", space.id)) + .relative() .w_full() .flex_none() - .p_1() - .rounded_md() - .border_1() - .border_color(if space.active { - colors.border_selected - } else { - transparent_black() - }) - .bg(if space.active { - sidebar_colors.card_active + .child(if held { + deferred(card).into_any_element() } else { - sidebar_colors.card_inactive + card.into_any_element() }) - .children(contents) .into_any_element(), ); } @@ -190,9 +637,10 @@ pub fn render( .id("sessions") .flex_1() .overflow_y_scroll() + .track_scroll(sorter.scroll_handle()) .py_1() .px_1p5() - .gap_2() + .gap(CARD_GAP) .children(cards), ) .child(deferred( @@ -370,3 +818,168 @@ fn row( ) }) } + +#[cfg(test)] +mod space_sorter_tests { + use super::*; + use gpui::{Context, Render, TestAppContext, Window, size}; + + fn id(value: u64) -> EntityId { + value.into() + } + + fn reordered_sorter(reduce_motion: bool) -> (SpaceSorter, Instant) { + let now = Instant::now(); + let mut sorter = SpaceSorter::default(); + sorter.sizes = [(id(1), px(40.)), (id(2), px(80.)), (id(3), px(20.))].into_iter().collect(); + sorter.held = Some(HeldSpace { + item: id(2), + order: vec![id(1), id(2), id(3)], + anchor: px(100.), + pointer: px(100.), + slot_moved: px(0.), + scroll_y: px(0.), + }); + assert!(sorter.reorder_held(0, px(16.), now, reduce_motion)); + (sorter, now) + } + + #[test] + fn variable_height_reorder_keeps_the_held_card_under_the_pointer() { + let (sorter, now) = reordered_sorter(false); + let held = sorter.held.as_ref().unwrap(); + + assert_eq!(held.order, vec![id(2), id(1), id(3)]); + // The held card's new slot starts 48 px earlier (40 px card + 8 px + // gap), so its transform adds exactly 48 px to keep it stationary. + assert_eq!(sorter.offset_of(id(2), now, false), px(48.)); + // The displaced 40 px card now has an 80 px card and the gap ahead of + // it, so FLIP initially draws it at its old position. + assert_eq!(sorter.offset_of(id(1), now, false), px(-88.)); + assert_eq!(sorter.offset_of(id(3), now, false), px(0.)); + + let mut drawn = vec![id(1), id(2), id(3)]; + sorter.arrange(&mut drawn, |item| *item); + assert_eq!(drawn, held.order); + } + + #[test] + fn accepted_drop_settles_for_150ms_and_reduce_motion_skips_flip() { + let (mut sorter, now) = reordered_sorter(false); + sorter.accept_drop(now, false); + assert!(sorter.held.is_none()); + assert_eq!(sorter.offset_of(id(2), now, false), px(48.)); + assert_eq!(sorter.offset_of(id(2), now + FLIP_DURATION, false), px(0.)); + + let (mut reduced, now) = reordered_sorter(true); + assert!(reduced.slides.is_empty()); + // Direct pointer carrying remains spatially correct; only the settle + // and displaced-card animations are removed. + assert_eq!(reduced.offset_of(id(2), now, true), px(48.)); + reduced.accept_drop(now, true); + assert_eq!(reduced.offset_of(id(2), now, true), px(0.)); + assert!(reduced.slides.is_empty()); + } + + #[test] + fn reversing_an_active_flip_does_not_jump() { + let (mut sorter, started) = reordered_sorter(false); + let reversed = started + FLIP_DURATION / 2; + let before = px(88.) + sorter.offset_of(id(1), reversed, false); + + assert!(sorter.reorder_held(2, px(16.), reversed, false)); + let after = sorter.offset_of(id(1), reversed, false); + assert!((f32::from(before - after)).abs() < f32::EPSILON); + + let held = sorter.held.as_ref().unwrap(); + assert_eq!(held.order, vec![id(1), id(3), id(2)]); + // Moving the held slot twice still leaves its painted top at the + // original 48 px while the pointer itself has not moved. + assert_eq!(px(76.) + held.carried(), px(48.)); + } + + #[test] + fn flip_curve_is_quintic_and_bounded() { + assert_eq!(flip_progress(Duration::ZERO), 0.); + assert!((flip_progress(FLIP_DURATION / 2) - 0.96875).abs() < f32::EPSILON); + assert_eq!(flip_progress(FLIP_DURATION), 1.); + assert_eq!(flip_progress(FLIP_DURATION * 2), 1.); + } + + #[test] + fn variable_height_midpoints_and_final_release_y_choose_legal_slots() { + let geometry = vec![ + (id(1), Bounds::new(point(px(0.), px(10.)), gpui::size(px(200.), px(40.)))), + (id(2), Bounds::new(point(px(0.), px(58.)), gpui::size(px(200.), px(80.)))), + (id(3), Bounds::new(point(px(0.), px(146.)), gpui::size(px(200.), px(20.)))), + ]; + let order = [id(1), id(2), id(3)]; + + // The second card is 80 px high, so a card coming from above does not + // displace it at 98 px exactly and does immediately after that point. + assert_eq!(crossed_index(id(1), &order, &geometry, px(98.)), None); + assert_eq!(crossed_index(id(1), &order, &geometry, px(98.1)), Some(1)); + // Empty space beyond the column belongs unambiguously to its end. + assert_eq!(crossed_index(id(2), &order, &geometry, px(-100.)), Some(0)); + assert_eq!(crossed_index(id(2), &order, &geometry, px(500.)), Some(2)); + + // Release uses Y alone. These coordinates can just as well have come + // from the workspace to the right of the sidebar. + assert_eq!(closest_index(id(2), &geometry, px(-100.)), 0); + assert_eq!(closest_index(id(2), &geometry, px(100.)), 1); + assert_eq!(closest_index(id(2), &geometry, px(500.)), 2); + } + + struct ScrollHarness { + sorter: SpaceSorter, + } + + impl Render for ScrollHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + v_flex() + .id("sorter-scroll-harness") + .w(px(200.)) + .h(px(100.)) + .overflow_y_scroll() + .track_scroll(self.sorter.scroll_handle()) + .gap(CARD_GAP) + .children([ + div().h(px(80.)).flex_none(), + div().h(px(80.)).flex_none(), + div().h(px(80.)).flex_none(), + ]) + } + } + + #[gpui::test] + fn edge_autoscroll_uses_tracked_bounds_without_letting_the_card_drift(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(240.), px(140.)), |_, _| ScrollHarness { + sorter: SpaceSorter::default(), + }); + cx.run_until_parked(); + + window + .update(cx, |harness, _, _| { + let viewport = harness.sorter.scroll.bounds(); + assert!(harness.sorter.scroll.max_offset().y > px(0.)); + let started = Instant::now(); + let pointer = viewport.bottom() + px(20.); + harness.sorter.held = Some(HeldSpace { + item: id(1), + order: vec![id(1), id(2), id(3)], + anchor: pointer, + pointer, + slot_moved: px(0.), + scroll_y: px(0.), + }); + harness.sorter.last_tick = Some(started); + + assert!(harness.sorter.tick(started + Duration::from_millis(16), px(16.), false)); + let scroll_y = harness.sorter.scroll.offset().y; + assert!(scroll_y < px(0.), "a pointer below the viewport scrolls down"); + let held = harness.sorter.held.as_ref().unwrap(); + assert_eq!(held.carried(), -scroll_y); + }) + .unwrap(); + } +} diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 15c4c006..53dbc248 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -43,6 +43,7 @@ fn main() { // Zed themes before applying the user-global selection. theme::init(theme::LoadThemes::All(Box::new(zed_assets::Assets)), cx); settings::init_themes(settings.resolved(), cx); + cx.set_reduce_motion(settings.resolved().reduce_motion); if let Err(error) = zed_assets::Assets.load_fonts(cx) { eprintln!("Chartr could not load its bundled fonts: {error}"); } diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index de55d21a..d51d517f 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -71,6 +71,7 @@ pub enum ThemeMode { #[derive(Debug, Clone, PartialEq)] pub struct ResolvedSettings { pub terminate_sessions_on_exit: bool, + pub reduce_motion: bool, pub theme_mode: ThemeMode, pub fixed_theme: String, pub light_theme: String, @@ -87,6 +88,7 @@ impl Default for ResolvedSettings { fn default() -> Self { Self { terminate_sessions_on_exit: false, + reduce_motion: false, theme_mode: ThemeMode::Fixed, fixed_theme: CHARTR_DARK.to_owned(), light_theme: CHARTR_LIGHT.to_owned(), @@ -147,6 +149,8 @@ pub struct GeneralContent { #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct AppearanceContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub reduce_motion: Option, #[serde(skip_serializing_if = "Option::is_none")] pub theme_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -185,6 +189,9 @@ impl SettingsContent { terminate_sessions_on_exit: general .and_then(|content| content.terminate_sessions_on_exit) .unwrap_or(defaults.terminate_sessions_on_exit), + reduce_motion: appearance + .and_then(|content| content.reduce_motion) + .unwrap_or(defaults.reduce_motion), theme_mode: appearance .and_then(|content| content.theme_mode) .unwrap_or(defaults.theme_mode), @@ -1041,6 +1048,7 @@ mod tests { assert_eq!(resolved.ui_font_family, "IBM Plex Sans"); assert_eq!(resolved.terminal_font_family, "Monaspace Neon"); assert_eq!(resolved.fixed_theme, CHARTR_DARK); + assert!(!resolved.reduce_motion); } #[test] @@ -1051,10 +1059,12 @@ mod tests { store .update(|content| { content.terminal.get_or_insert_default().font_size = Some(17.); + content.appearance.get_or_insert_default().reduce_motion = Some(true); }) .unwrap(); let relaunched = SettingsStore::load(&file); assert_eq!(relaunched.resolved().terminal_font_size, 17.); + assert!(relaunched.resolved().reduce_motion); assert!(fs::read_to_string(file).unwrap().starts_with("# Chartr-zeddy")); } diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index 04189911..55cbc4b0 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -166,6 +166,7 @@ impl SettingsWindow { ) { match settings::update_global(cx, mutate) { Ok(resolved) => { + cx.set_reduce_motion(resolved.reduce_motion); if apply_theme { settings::apply_theme(&resolved, cx); } @@ -208,6 +209,18 @@ impl SettingsWindow { ); } + fn set_reduce_motion(&mut self, enabled: bool, cx: &mut Context) { + self.update_settings( + move |content| { + content.appearance.get_or_insert_with(AppearanceContent::default).reduce_motion = + Some(enabled); + }, + false, + false, + cx, + ); + } + fn set_theme(&mut self, target: ThemeTarget, theme: String, cx: &mut Context) { self.update_settings( move |content| { @@ -701,6 +714,9 @@ impl SettingsWindow { .collect(); let fixed_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::Fixed, cx)); let system_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::System, cx)); + let reduce_motion = settings.reduce_motion; + let toggle_reduce_motion = + cx.listener(move |this, _, _, cx| this.set_reduce_motion(!reduce_motion, cx)); let font = cx.weak_entity(); let smaller = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(-1., cx)); let larger = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(1., cx)); @@ -808,6 +824,24 @@ impl SettingsWindow { .on_click(larger), ), ) + .child(setting_label("Motion")) + .child( + h_flex() + .justify_between() + .gap_4() + .child( + v_flex().child(Label::new("Reduce motion").size(UI_LABEL_DEFAULT)).child( + Label::new("Disable movement animations when space cards are sorted.") + .size(UI_LABEL_SMALL) + .color(Color::Muted), + ), + ) + .child( + Button::new("reduce-motion", if reduce_motion { "On" } else { "Off" }) + .toggle_state(reduce_motion) + .on_click(toggle_reduce_motion), + ), + ) .into_any_element() } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 1f451966..be9f7fe0 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -534,6 +534,7 @@ impl Space { | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } + | Action::BeginSpaceDrag { .. } | Action::OpenSettings => {} } cx.notify(); diff --git a/crates/zeddy/src/spaces.rs b/crates/zeddy/src/spaces.rs index 885dec95..be916559 100644 --- a/crates/zeddy/src/spaces.rs +++ b/crates/zeddy/src/spaces.rs @@ -154,6 +154,39 @@ impl Registry { Ok(()) } + /// Replaces the registered-space order with a complete path permutation. + /// + /// The candidate is validated before the in-memory registry changes. A + /// failed write restores the previous order, so the sidebar can reject a + /// drop without ever presenting an arrangement the next launch would lose. + pub fn reorder(&mut self, paths: &[PathBuf]) -> Result { + if paths.len() != self.spaces.len() { + return Err(Error::BadReorder); + } + let mut remaining = self.spaces.clone(); + let mut candidate = Vec::with_capacity(remaining.len()); + for path in paths { + let Some(index) = remaining.iter().position(|space| same_path(space.path(), path)) + else { + return Err(Error::BadReorder); + }; + candidate.push(remaining.remove(index)); + } + if !remaining.is_empty() { + return Err(Error::BadReorder); + } + if candidate == self.spaces { + return Ok(false); + } + + let previous = std::mem::replace(&mut self.spaces, candidate); + if let Err(error) = self.save() { + self.spaces = previous; + return Err(error); + } + Ok(true) + } + pub fn relocate( &mut self, old_path: impl AsRef, @@ -298,6 +331,7 @@ pub enum Error { NoConfigRoot, BadName, DuplicateFolder(PathBuf), + BadReorder, } impl Error { @@ -340,6 +374,9 @@ impl fmt::Display for Error { Self::DuplicateFolder(path) => { write!(f, "{} is already registered as another space", path.display()) } + Self::BadReorder => { + write!(f, "a space reorder must name every registered folder exactly once") + } } } } @@ -357,7 +394,8 @@ impl std::error::Error for Error { | Self::NotUnicode { .. } | Self::NoConfigRoot | Self::BadName - | Self::DuplicateFolder(_) => None, + | Self::DuplicateFolder(_) + | Self::BadReorder => None, } } } @@ -446,4 +484,69 @@ mod tests { assert!(written.contains("future_top = \"kept\"")); assert!(written.contains("future_row = 42")); } + + #[test] + fn reordered_spaces_survive_relaunch_in_file_order() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + let third = temp.path().join("third"); + for folder in [&first, &second, &third] { + fs::create_dir(folder).unwrap(); + } + let file = temp.path().join("spaces.toml"); + let mut registry = Registry::load(&file).unwrap(); + for folder in [&first, &second, &third] { + registry.register(folder).unwrap(); + } + + assert!(registry.reorder(&[third.clone(), first.clone(), second.clone()]).unwrap()); + let relaunched = Registry::load(file).unwrap(); + let order: Vec<_> = relaunched.spaces().iter().map(|space| space.path()).collect(); + assert_eq!(order, vec![&third, &first, &second]); + } + + #[test] + fn invalid_reorders_are_rejected_without_mutating_the_registry() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + fs::create_dir(&first).unwrap(); + fs::create_dir(&second).unwrap(); + let mut registry = Registry::load(temp.path().join("spaces.toml")).unwrap(); + registry.register(&first).unwrap(); + registry.register(&second).unwrap(); + + assert!(matches!(registry.reorder(std::slice::from_ref(&first)), Err(Error::BadReorder))); + assert!(matches!( + registry.reorder(&[first.clone(), first.clone()]), + Err(Error::BadReorder) + )); + assert_eq!(registry.spaces()[0].path(), &first); + assert_eq!(registry.spaces()[1].path(), &second); + } + + #[test] + fn no_op_avoids_io_and_a_failed_write_rolls_back_memory() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + fs::create_dir(&first).unwrap(); + fs::create_dir(&second).unwrap(); + let config = temp.path().join("config"); + let file = config.join("spaces.toml"); + let mut registry = Registry::load(&file).unwrap(); + registry.register(&first).unwrap(); + registry.register(&second).unwrap(); + + // Leave the loaded registry pointing at a path whose parent is now a + // plain file. This reliably fails staging on every platform without + // relying on permission behavior under a privileged test runner. + fs::rename(&config, temp.path().join("moved-config")).unwrap(); + fs::write(&config, "not a directory").unwrap(); + + assert!(!registry.reorder(&[first.clone(), second.clone()]).unwrap()); + assert!(registry.reorder(&[second, first.clone()]).is_err()); + assert_eq!(registry.spaces()[0].path(), &first); + } } diff --git a/docs/acceptance.md b/docs/acceptance.md index d0d22804..fccd7175 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -24,6 +24,7 @@ Review at 700×900, 1100×720, and a maximized window in both Chartr Dark and Chartr Light. Capture and compare: - empty Free sessions startup, one folder, and several spaces; +- several variable-height space cards before, during, and after a reorder; - Sidebar / All Spaces, Sidebar / Active Space, and Tabbed mode; - empty space, one standalone tab, nested horizontal/vertical panes, resized dividers, and automatic split collapse after its last item moves or closes; @@ -76,14 +77,27 @@ entry disappears, the target group remains selected, and no item is duplicated. Inspect the GPUI accessibility tree on macOS and Linux. Tabs and Settings navigation must expose roles, labels, and selection; Zed buttons and menus must retain their labels and focus rings; contrast must remain readable in both -themes. Chartr introduces no animation, so reduced-motion mode requires no -alternate transition path. +themes. + +In Sidebar / All Spaces, drag the top, middle, and bottom cards by their headings. +The complete card must track the pointer vertically without horizontal drift; +neighbouring variable-height cards must change places only after their midpoints +are crossed. Move the pointer beyond both vertical scroll edges and confirm +Zed-style autoscroll. Move it horizontally into the workspace, continue upward +or downward, and release there: the card must settle in the closest legal slot +for that final Y. `Escape` and a simulated `spaces.toml` write failure must snap +back to the model order. Repeat rapid direction reversals to confirm the 150 ms +FLIP settle remains continuous. Enable Appearance / Reduce Motion and repeat: +direct pointer carrying and sorting remain, while displaced-card and release +settle animations are absent. ## Persistence and lifecycle -Relaunch after changing window bounds, sidebar width/scope, mode, space names, +Relaunch after changing window bounds, sidebar width/scope, mode, full space +order (including Free sessions and a recovered missing folder), space names, outer-tab order, split ratios, active groups/panes/items, plugin Settings, and a -missing folder. Confirm normal exit adopts detached terminals; item close kills exactly one session; +missing folder. Confirm the sidebar and `spaces.toml` retain the committed order. +Confirm normal exit adopts detached terminals; item close kills exactly one session; closing a populated pane or folder space confirms and kills all descendants; session-bound plugins cascade; disabling or revoking a plugin closes every live instance; and stale backend/plugin records are summarized without corrupting the diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md index 4d02094c..ab3e0b4b 100644 --- a/docs/adr/0005-spaces-follow-zed-multi-workspace.md +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -26,10 +26,13 @@ node-runtime systems that zeddy does not use. The folder registry lives at `$XDG_CONFIG_HOME/chartr-zeddy/spaces.toml`, with platform fallbacks, file order as display order, duplicate suppression, and -unknown TOML keys preserved. Window bounds, chrome choice, ordered outer tabs, -pane trees, item ownership, and restorable plugin state live in Chartr's SQLite -state store. A pre-outer-tab pane tree migrates to one grouped outer entry. The -rewrite deliberately does not import or mutate older Chartr registries. +unknown TOML keys preserved. A committed sidebar reorder atomically rewrites +that file order; a failed write restores the previous registry and drawn order. +Window bounds, chrome choice, complete space order (including the synthetic and +recovered spaces), ordered outer tabs, pane trees, item ownership, and restorable +plugin state live in Chartr's SQLite state store. A pre-outer-tab pane tree +migrates to one grouped outer entry. The rewrite deliberately does not import or +mutate older Chartr registries. Free sessions are the one synthetic space. They use the operator's home directory and have no registry row. A registered home-directory row is not @@ -46,6 +49,14 @@ duplicate inner bar. Both reuse Zed `ui` components for tabs, buttons, labels, icons, colors, focus tracking, and scroll containers. There is no custom popup, menu state machine, or parallel widget kit. +All-Spaces sidebar cards have one focused sorter owned by the root window rather +than a general drag-and-drop framework. A heading drag carries the whole card on +the Y axis even after horizontal overdrag, compares final pointer Y against +measured variable-height midpoints, and uses the tracked Zed scroll handle for +edge autoscroll. Reordered neighbours and the released card use an interruptible +150 ms quintic FLIP; GPUI's application-wide reduced-motion flag removes those +animations without changing direct manipulation. + Standalone terminal labels are live backend presentation: detected agent, non-shell foreground process, then Herdr's persistent tab label or number. They are refreshed on the same two-second cadence as session discovery and are not From d93a3596e46ef0d5fe8e61740b25e69c3456bdb4 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 21:49:39 +0800 Subject: [PATCH 016/110] Add pane ungrouping --- README.md | 2 +- crates/zeddy/src/app.rs | 7 ++ crates/zeddy/src/chrome.rs | 2 +- crates/zeddy/src/chrome/sidebar.rs | 46 +++++--- crates/zeddy/src/chrome/tabs.rs | 28 ++++- crates/zeddy/src/space.rs | 14 ++- crates/zeddy/src/workspace.rs | 110 ++++++++++++++++++ docs/acceptance.md | 2 +- .../0005-spaces-follow-zed-multi-workspace.md | 2 +- 9 files changed, 184 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 726c5f2c..f0067879 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Terminal titles follow Herdr's live view of the PTY, as in Chartr-rs: a detected agent wins, otherwise the non-shell foreground process is shown, and an idle shell falls back to Herdr's persistent tab label or number. The same two-second backend refresh that discovers sessions updates and clears these inferred -titles. Collapsed pane groups use the neutral title **Grouped Tabs**. +titles. Collapsed pane groups use their item count as the title, such as **5 Tabs**. ## Settings and persistence diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index f2be4d39..13413c34 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -812,6 +812,13 @@ impl Zeddy { let ids = space.read(cx).tab_item_ids(tab); self.request_bulk_close(space, ids, false, "group", window, cx); } + Action::UngroupPane { space, tab } => { + if let Some(space) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + space.update(cx, |space, _| space.ungroup_pane(tab)); + } + } Action::MoveWorkspaceTab { space, tab, target_index } => { if let Some(space) = self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 3792fb0f..0ec93bbb 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -39,7 +39,6 @@ pub struct Entry { pub selected: bool, pub closable: bool, pub grouped: bool, - pub item_count: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -58,6 +57,7 @@ pub enum Action { Select { space: Option, item: ItemId }, Close { space: Option, item: ItemId }, CloseGroup { space: EntityId, tab: WorkspaceTabId }, + UngroupPane { space: EntityId, tab: WorkspaceTabId }, MoveWorkspaceTab { space: EntityId, tab: WorkspaceTabId, target_index: usize }, BeginSpaceDrag { at: Pixels }, CloseSpace { space: EntityId }, diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 21cf56d3..281a1f10 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -13,7 +13,7 @@ use gpui::{ Anchor, Bounds, EntityId, MouseButton, Pixels, Point, Rems, Role, ScrollHandle, deferred, point, px, transparent_black, }; -use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*}; +use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*, right_click_menu}; use super::Emit; @@ -689,8 +689,9 @@ fn row( backgrounds: SelectionRowBackgrounds, on: Emit, cx: &App, -) -> impl IntoElement { +) -> AnyElement { let close = on.clone(); + let ungroup = on.clone(); let move_tab = on.clone(); let select = entry.key; @@ -713,21 +714,12 @@ fn row( }; let close_button_width = IconSize::XSmall.rems() + DynamicSpacing::Base04.rems(cx) * 2.; let close_slot_width = close_button_width - DynamicSpacing::Base06.rems(cx); - let end_slot = h_flex() - .gap_1() - .when(grouped, |slot| { - slot.child( - Label::new(format!("{} tabs", entry.item_count)) - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ) - }) - .when(entry.closable, |slot| { - // Reserve exactly the portion of the button not already covered - // by ListItem's trailing Base06 inset. The real control is an - // unclipped overlay at the wrapper level below. - slot.child(div().w(close_slot_width).flex_none()) - }); + let end_slot = h_flex().when(entry.closable, |slot| { + // Reserve exactly the portion of the button not already covered + // by ListItem's trailing Base06 inset. The real control is an + // unclipped overlay at the wrapper level below. + slot.child(div().w(close_slot_width).flex_none()) + }); let close_button = entry.closable.then(|| { IconButton::new(("close", index), IconName::Close).icon_size(IconSize::XSmall).on_click( move |_, window, cx| { @@ -747,7 +739,7 @@ fn row( // `ListItem` deliberately owns row visuals and click semantics. This thin // wrapper owns sidebar-tab dragging, which Zed's generic row does not. - div() + let row = div() .id(("session-drag", index)) .relative() .group("session") @@ -816,7 +808,23 @@ fn row( .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(close_button), ) - }) + }); + + if grouped { + right_click_menu(format!("group-row-menu-{space:?}-{}", close_tab.get())) + .trigger(move |_, _, _| row) + .menu(move |window, cx| { + let ungroup = ungroup.clone(); + ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Ungroup", None, move |window, cx| { + ungroup(Action::UngroupPane { space, tab: close_tab }, window, cx) + }) + }) + }) + .into_any_element() + } else { + row.into_any_element() + } } #[cfg(test)] diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index b24412f4..b3c3a6a2 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -6,7 +6,10 @@ //! identity. use gpui::Role; -use ui::{ButtonSize, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*}; +use ui::{ + ButtonSize, ContextMenu, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*, + right_click_menu, +}; use super::Emit; @@ -48,7 +51,7 @@ fn tab( entry: &Entry, on: Emit, cx: &App, -) -> impl IntoElement { +) -> AnyElement { let close = on.clone(); let position = if index == 0 { TabPosition::First @@ -59,6 +62,7 @@ fn tab( }; let select = entry.key; let select_item = on.clone(); + let ungroup = on.clone(); let move_tab = on; let close_key = entry.key; let close_tab = entry.tab; @@ -96,7 +100,7 @@ fn tab( }) .into_any_element() }); - Tab::new(("tab", index)) + let tab = Tab::new(("tab", index)) .role(Role::Tab) .aria_label(if entry.grouped { format!("Pane group: {}", entry.title) @@ -146,5 +150,21 @@ fn tab( cx, )) .end_slot::(close_slot) - .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()) + .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()); + + if grouped { + right_click_menu(format!("group-tab-menu-{space:?}-{}", close_tab.get())) + .trigger(move |_, _, _| tab) + .menu(move |window, cx| { + let ungroup = ungroup.clone(); + ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Ungroup", None, move |window, cx| { + ungroup(Action::UngroupPane { space, tab: close_tab }, window, cx) + }) + }) + }) + .into_any_element() + } else { + tab.into_any_element() + } } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index be9f7fe0..eae7db3a 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -454,14 +454,17 @@ impl Space { tab: tab.id, pane, index, - title: if grouped { "Grouped Tabs".to_owned() } else { item.title() }, + title: if grouped { + format!("{} Tabs", tab.layout.item_count()) + } else { + item.title() + }, status: (!grouped).then(|| item.status()).flatten(), process_running: !grouped && item.process_running(), ended: !grouped && item.ended(), selected: self.layout.active_tab_id() == Some(tab.id), closable: true, grouped, - item_count: tab.layout.item_count(), }) }) .collect() @@ -489,6 +492,12 @@ impl Space { } } + pub fn ungroup_pane(&mut self, tab: WorkspaceTabId) { + if let Err(error) = self.layout.ungroup_tab(tab) { + self.problem = Some(error.to_string()); + } + } + pub fn all_item_ids(&self) -> Vec { self.layout.item_ids().collect() } @@ -531,6 +540,7 @@ impl Space { | Action::NewInSpace { .. } | Action::MoveWorkspaceTab { .. } | Action::CloseGroup { .. } + | Action::UngroupPane { .. } | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index f25e3dce..fb9ac51c 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -989,6 +989,61 @@ impl WorkspaceTabs { Ok(()) } + /// Expand one grouped workspace back into standalone outer tabs. + /// + /// Items follow pane-tree order and retain their order within each pane, + /// so ungrouping is deterministic even for recursively nested splits. + pub fn ungroup_tab(&mut self, id: WorkspaceTabId) -> Result<(), ModelError> { + let index = self + .tabs + .iter() + .position(|candidate| candidate.id == id) + .ok_or(ModelError::WorkspaceTabNotFound(id))?; + let tab = &self.tabs[index]; + if !tab.is_grouped() { + return Ok(()); + } + + let items: Vec<_> = tab + .layout + .center + .panes() + .into_iter() + .flat_map(|pane| { + tab.layout.pane(pane).into_iter().flat_map(|pane| pane.items().iter().copied()) + }) + .collect(); + if items.is_empty() { + return Ok(()); + } + + let was_active = self.active == Some(id); + let previously_active = self.active; + let representative = tab.representative_item(); + self.tabs.remove(index); + self.activation_history.retain(|candidate| *candidate != id); + if was_active { + self.active = None; + } + + let mut representative_tab = None; + for (offset, item) in items.into_iter().enumerate() { + let standalone = self.push_standalone_at(item, index + offset)?; + if Some(item) == representative { + representative_tab = Some(standalone); + } + } + + if was_active { + if let Some(active) = representative_tab { + self.activate_tab(active)?; + } + } else if let Some(active) = previously_active { + self.activate_tab(active)?; + } + Ok(()) + } + pub fn remove_item(&mut self, item: ItemId) -> Result<(), ModelError> { let (tab, _) = self.location(item).ok_or(ModelError::ItemNotFound(item))?; self.workspace_mut(tab).expect("known workspace tab").remove_item(item)?; @@ -1151,6 +1206,61 @@ mod tests { restored.validate().unwrap(); } + #[test] + fn ungrouping_restores_items_as_outer_tabs_in_pane_and_tab_order() { + let mut tabs = WorkspaceTabs::new(); + let items: Vec<_> = (0..5).map(|_| tabs.alloc_item()).collect(); + let outer: Vec<_> = items.iter().map(|item| tabs.push_standalone(*item).unwrap()).collect(); + let left = tabs.workspace(outer[1]).unwrap().active_pane(); + let right = + tabs.workspace_mut(outer[1]).unwrap().split_pane(left, SplitDirection::Right).unwrap(); + + tabs.move_item(items[2], outer[2], PaneId(1), outer[1], left, None).unwrap(); + tabs.move_item(items[3], outer[3], PaneId(1), outer[1], right, None).unwrap(); + tabs.ungroup_tab(outer[1]).unwrap(); + + assert_eq!( + tabs.tabs().iter().filter_map(WorkspaceTab::representative_item).collect::>(), + items, + ); + assert!(tabs.tabs().iter().all(|tab| !tab.is_grouped())); + assert_eq!(tabs.active_item(), Some(items[3])); + tabs.validate().unwrap(); + } + + #[test] + fn ungrouping_an_inactive_group_preserves_the_active_outer_tab() { + let mut tabs = WorkspaceTabs::new(); + let items: Vec<_> = (0..3).map(|_| tabs.alloc_item()).collect(); + let outer: Vec<_> = items.iter().map(|item| tabs.push_standalone(*item).unwrap()).collect(); + let target_pane = tabs.workspace(outer[0]).unwrap().active_pane(); + tabs.move_item(items[1], outer[1], PaneId(1), outer[0], target_pane, None).unwrap(); + tabs.activate_tab(outer[2]).unwrap(); + + tabs.ungroup_tab(outer[0]).unwrap(); + + assert_eq!(tabs.active_tab_id(), Some(outer[2])); + assert_eq!(tabs.active_item(), Some(items[2])); + assert!(tabs.tabs().iter().all(|tab| !tab.is_grouped())); + tabs.validate().unwrap(); + } + + #[test] + fn ungrouping_a_single_item_with_an_empty_split_makes_it_standalone() { + let mut tabs = WorkspaceTabs::new(); + let item = tabs.alloc_item(); + let grouped = tabs.push_standalone(item).unwrap(); + let occupied = tabs.workspace(grouped).unwrap().active_pane(); + tabs.workspace_mut(grouped).unwrap().split_pane(occupied, SplitDirection::Right).unwrap(); + + tabs.ungroup_tab(grouped).unwrap(); + + assert_eq!(tabs.tabs().len(), 1); + assert_eq!(tabs.active_item(), Some(item)); + assert!(!tabs.tabs()[0].is_grouped()); + tabs.validate().unwrap(); + } + #[test] fn standalone_outer_tabs_drop_into_a_group_center_or_any_edge() { for direction in [ diff --git a/docs/acceptance.md b/docs/acceptance.md index fccd7175..2b4e9130 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -34,7 +34,7 @@ Chartr Light. Capture and compare: to only the selected group; - live terminal titles changing from their Herdr tab number to `nano`, `htop`, or a detected agent and back when that foreground process exits; every - collapsed pane group remains titled `Grouped Tabs`; + collapsed pane group title remains its current item count, such as `5 Tabs`; - Zed-style transient pane-body drop highlights: full-content center and half-content left, right, top, and bottom targets, including nearest-edge corner resolution and no split target over a pane's tab bar; diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md index ab3e0b4b..4fc27aed 100644 --- a/docs/adr/0005-spaces-follow-zed-multi-workspace.md +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -60,7 +60,7 @@ animations without changing direct manipulation. Standalone terminal labels are live backend presentation: detected agent, non-shell foreground process, then Herdr's persistent tab label or number. They are refreshed on the same two-second cadence as session discovery and are not -persisted locally. A collapsed pane group is deliberately just `Grouped Tabs`; +persisted locally. A collapsed pane group is deliberately just its item count, such as `5 Tabs`; its children retain their individual live labels in the pane-local tab bars. ## Consequence From cbdcbddf00d373db115bc023bb72075be4ad5806 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 22:00:14 +0800 Subject: [PATCH 017/110] Add names for grouped tabs --- README.md | 3 +- crates/zeddy/src/app.rs | 129 +++++++++++++++++- crates/zeddy/src/chrome.rs | 1 + crates/zeddy/src/chrome/sidebar.rs | 7 +- crates/zeddy/src/chrome/tabs.rs | 7 +- crates/zeddy/src/space.rs | 15 +- crates/zeddy/src/workspace.rs | 54 +++++++- docs/acceptance.md | 4 +- .../0005-spaces-follow-zed-multi-workspace.md | 5 +- 9 files changed, 213 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f0067879..4c83b837 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ Terminal titles follow Herdr's live view of the PTY, as in Chartr-rs: a detected agent wins, otherwise the non-shell foreground process is shown, and an idle shell falls back to Herdr's persistent tab label or number. The same two-second backend refresh that discovers sessions updates and clears these inferred -titles. Collapsed pane groups use their item count as the title, such as **5 Tabs**. +titles. Collapsed pane groups can be renamed from their context menu and otherwise +use their item count as the title, such as **5 tabs**. ## Settings and persistence diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 13413c34..08847f64 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -141,6 +141,7 @@ pub struct Zeddy { command_palette_query: String, command_palette_selected: usize, rename_space: Option, + rename_group: Option<(EntityId, WorkspaceTabId)>, rename_input: Entity, rename_query: String, sidebar_scope: SidebarScope, @@ -162,7 +163,7 @@ impl Zeddy { }) .detach(); let command_palette_input = cx.new(|cx| TextInput::new("Type a command…", cx)); - let rename_input = cx.new(|cx| TextInput::new("Type a space name…", cx)); + let rename_input = cx.new(|cx| TextInput::new("Type a name…", cx)); cx.subscribe(&command_palette_input, |this, input, _: &InputEvent, cx| { this.command_palette_query = input.read(cx).text().to_owned(); this.command_palette_selected = 0; @@ -207,6 +208,7 @@ impl Zeddy { command_palette_query: String::new(), command_palette_selected: 0, rename_space: None, + rename_group: None, rename_input, rename_query: String::new(), sidebar_scope: saved.window.sidebar_scope, @@ -322,6 +324,7 @@ impl Zeddy { command_palette_query: String::new(), command_palette_selected: 0, rename_space: None, + rename_group: None, rename_input, rename_query: String::new(), sidebar_scope: saved.window.sidebar_scope, @@ -819,6 +822,20 @@ impl Zeddy { space.update(cx, |space, _| space.ungroup_pane(tab)); } } + Action::RenameGroup { space, tab } => { + if let Some(target) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + self.rename_space = None; + self.rename_group = Some((space, tab)); + self.rename_query = + target.read(cx).group_name(tab).unwrap_or_default().to_owned(); + self.rename_input.update(cx, |input, cx| { + input.set_text(self.rename_query.clone(), true, cx) + }); + window.focus(&self.rename_input.focus_handle(cx), cx); + } + } Action::MoveWorkspaceTab { space, tab, target_index } => { if let Some(space) = self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() @@ -831,6 +848,7 @@ impl Zeddy { if let Some(target) = self.spaces.iter().find(|candidate| candidate.entity_id() == space) { + self.rename_group = None; self.rename_space = Some(space); self.rename_query = target.read(cx).name().to_owned(); self.rename_input.update(cx, |input, cx| { @@ -1129,6 +1147,24 @@ impl Zeddy { cx.notify(); } + fn commit_group_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some((space_id, tab)) = self.rename_group.take() else { + return; + }; + // Read from the input directly so Enter always commits the latest IME + // transaction, even before the subscription's mirrored value flushes. + let name = self.rename_input.read(cx).text().trim().to_owned(); + let name = (!name.is_empty()).then_some(name); + self.rename_query.clear(); + self.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&self.focus, cx); + if let Some(space) = self.spaces.iter().find(|space| space.entity_id() == space_id).cloned() + { + space.update(cx, |space, _| space.rename_group(tab, name)); + } + cx.notify(); + } + fn locate_space(&mut self, id: EntityId, cx: &mut Context) { let chosen = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -1437,18 +1473,23 @@ impl Zeddy { } fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { - if self.rename_space.is_some() { + if self.rename_space.is_some() || self.rename_group.is_some() { match event.keystroke.key.as_str() { "escape" => { cx.stop_propagation(); self.rename_space = None; + self.rename_group = None; self.rename_query.clear(); self.rename_input.update(cx, |input, cx| input.clear(cx)); window.focus(&self.focus, cx); } "enter" => { cx.stop_propagation(); - self.commit_space_rename(window, cx); + if self.rename_group.is_some() { + self.commit_group_rename(window, cx); + } else { + self.commit_space_rename(window, cx); + } return; } _ => return, @@ -2764,6 +2805,84 @@ impl Zeddy { .into_any_element(), ) } + + fn rename_group_overlay(&mut self, cx: &mut Context) -> Option { + self.rename_group?; + let cancel_scrim = cx.listener(|this, _, window, cx| { + this.rename_group = None; + this.rename_query.clear(); + this.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&this.focus, cx); + cx.notify(); + }); + let cancel_button = cx.listener(|this, _, window, cx| { + this.rename_group = None; + this.rename_query.clear(); + this.rename_input.update(cx, |input, cx| input.clear(cx)); + window.focus(&this.focus, cx); + cx.notify(); + }); + let save = cx.listener(|this, _, window, cx| this.commit_group_rename(window, cx)); + Some( + div() + .id("rename-group-scrim") + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .bg(gpui::black().opacity(0.35)) + .on_mouse_down(gpui::MouseButton::Left, cancel_scrim) + .child( + v_flex() + .id("rename-group-dialog") + .absolute() + .top(px(96.)) + .left(relative(0.5)) + .ml(px(-220.)) + .w(px(440.)) + .p_4() + .gap_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .shadow_lg() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(Label::new("Rename Group").size(UI_LABEL_LARGE)) + .child( + v_flex() + .gap_1() + .child( + h_flex() + .h(px(36.)) + .px_2() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_focused) + .bg(cx.theme().colors().editor_background) + .child(self.rename_input.clone()), + ) + .child( + Label::new("Leave blank to use the tab count.") + .size(UI_LABEL_SMALL) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .justify_end() + .gap_1() + .child( + Button::new("cancel-group-rename", "Cancel") + .on_click(cancel_button), + ) + .child(Button::new("save-group-rename", "Rename").on_click(save)), + ), + ) + .into_any_element(), + ) + } } impl Focusable for Zeddy { @@ -2836,12 +2955,15 @@ impl Render for Zeddy { let command_palette = self.command_palette(cx); let rename_space = self.rename_space_overlay(cx); + let rename_group = self.rename_group_overlay(cx); div() .relative() .track_focus(&self.focus) .key_context(if self.rename_space.is_some() { "RenameSpace" + } else if self.rename_group.is_some() { + "RenameGroup" } else if self.command_palette_open { "CommandPalette" } else { @@ -2935,6 +3057,7 @@ impl Render for Zeddy { .child(body) .children(command_palette) .children(rename_space) + .children(rename_group) } } diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 0ec93bbb..25f7ad77 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -58,6 +58,7 @@ pub enum Action { Close { space: Option, item: ItemId }, CloseGroup { space: EntityId, tab: WorkspaceTabId }, UngroupPane { space: EntityId, tab: WorkspaceTabId }, + RenameGroup { space: EntityId, tab: WorkspaceTabId }, MoveWorkspaceTab { space: EntityId, tab: WorkspaceTabId, target_index: usize }, BeginSpaceDrag { at: Pixels }, CloseSpace { space: EntityId }, diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 281a1f10..86fe1123 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -692,6 +692,7 @@ fn row( ) -> AnyElement { let close = on.clone(); let ungroup = on.clone(); + let rename = on.clone(); let move_tab = on.clone(); let select = entry.key; @@ -815,8 +816,12 @@ fn row( .trigger(move |_, _, _| row) .menu(move |window, cx| { let ungroup = ungroup.clone(); + let rename = rename.clone(); ContextMenu::build(window, cx, move |menu, _, _| { - menu.entry("Ungroup", None, move |window, cx| { + menu.entry("Rename", None, move |window, cx| { + rename(Action::RenameGroup { space, tab: close_tab }, window, cx) + }) + .entry("Ungroup", None, move |window, cx| { ungroup(Action::UngroupPane { space, tab: close_tab }, window, cx) }) }) diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index b3c3a6a2..8c46a7c2 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -63,6 +63,7 @@ fn tab( let select = entry.key; let select_item = on.clone(); let ungroup = on.clone(); + let rename = on.clone(); let move_tab = on; let close_key = entry.key; let close_tab = entry.tab; @@ -157,8 +158,12 @@ fn tab( .trigger(move |_, _, _| tab) .menu(move |window, cx| { let ungroup = ungroup.clone(); + let rename = rename.clone(); ContextMenu::build(window, cx, move |menu, _, _| { - menu.entry("Ungroup", None, move |window, cx| { + menu.entry("Rename", None, move |window, cx| { + rename(Action::RenameGroup { space, tab: close_tab }, window, cx) + }) + .entry("Ungroup", None, move |window, cx| { ungroup(Action::UngroupPane { space, tab: close_tab }, window, cx) }) }) diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index eae7db3a..88cbbdaf 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -455,7 +455,9 @@ impl Space { pane, index, title: if grouped { - format!("{} Tabs", tab.layout.item_count()) + tab.name() + .map(str::to_owned) + .unwrap_or_else(|| format!("{} tabs", tab.layout.item_count())) } else { item.title() }, @@ -498,6 +500,16 @@ impl Space { } } + pub fn group_name(&self, tab: WorkspaceTabId) -> Option<&str> { + self.layout.tab(tab).filter(|tab| tab.is_grouped()).and_then(|tab| tab.name()) + } + + pub fn rename_group(&mut self, tab: WorkspaceTabId, name: Option) { + if let Err(error) = self.layout.rename_tab(tab, name) { + self.problem = Some(error.to_string()); + } + } + pub fn all_item_ids(&self) -> Vec { self.layout.item_ids().collect() } @@ -541,6 +553,7 @@ impl Space { | Action::MoveWorkspaceTab { .. } | Action::CloseGroup { .. } | Action::UngroupPane { .. } + | Action::RenameGroup { .. } | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } diff --git a/crates/zeddy/src/workspace.rs b/crates/zeddy/src/workspace.rs index fb9ac51c..ce6f9ace 100644 --- a/crates/zeddy/src/workspace.rs +++ b/crates/zeddy/src/workspace.rs @@ -750,6 +750,8 @@ impl Workspace { pub struct WorkspaceTab { pub id: WorkspaceTabId, pub layout: Workspace, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, } impl WorkspaceTab { @@ -761,6 +763,10 @@ impl WorkspaceTab { self.layout.pane(self.layout.active_pane()).and_then(Pane::active) } + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + /// The item outer chrome can use to identify this tab even when its active /// pane is an empty Zed-style drop target. pub fn representative_item(&self) -> Option { @@ -832,7 +838,7 @@ impl<'de> Deserialize<'de> for WorkspaceTabs { } else { let id = WorkspaceTabId(1); Self { - tabs: vec![WorkspaceTab { id, layout }], + tabs: vec![WorkspaceTab { id, layout, name: None }], active: Some(id), activation_history: vec![id], next_tab_id: 2, @@ -871,6 +877,12 @@ impl WorkspaceTabs { } fn normalize(&mut self) { + for tab in &mut self.tabs { + tab.name = tab.name.take().and_then(|name| { + let name = name.trim(); + (!name.is_empty()).then(|| name.to_owned()) + }); + } let known: HashSet<_> = self.tabs.iter().map(|tab| tab.id).collect(); self.activation_history.retain(|tab| known.contains(tab)); if self.active.is_none_or(|active| !known.contains(&active)) { @@ -973,11 +985,24 @@ impl WorkspaceTabs { self.next_tab_id += 1; let mut layout = Workspace::new(); layout.add_item(item, None, None)?; - self.tabs.insert(index.min(self.tabs.len()), WorkspaceTab { id, layout }); + self.tabs.insert(index.min(self.tabs.len()), WorkspaceTab { id, layout, name: None }); self.activate_tab(id)?; Ok(id) } + pub fn rename_tab( + &mut self, + id: WorkspaceTabId, + name: Option, + ) -> Result<(), ModelError> { + let tab = self.tab_mut(id).ok_or(ModelError::WorkspaceTabNotFound(id))?; + tab.name = name.and_then(|name| { + let name = name.trim(); + (!name.is_empty()).then(|| name.to_owned()) + }); + Ok(()) + } + pub fn move_tab(&mut self, tab: WorkspaceTabId, destination: usize) -> Result<(), ModelError> { let source = self .tabs @@ -1313,6 +1338,31 @@ mod tests { restored.validate().unwrap(); } + #[test] + fn workspace_tab_names_round_trip_and_empty_names_restore_the_default() { + let mut tabs = WorkspaceTabs::new(); + let first = tabs.alloc_item(); + let second = tabs.alloc_item(); + let group = tabs.push_standalone(first).unwrap(); + let source = tabs.push_standalone(second).unwrap(); + let target_pane = tabs.workspace(group).unwrap().active_pane(); + tabs.move_item(second, source, PaneId(1), group, target_pane, None).unwrap(); + + tabs.rename_tab(group, Some(" Build Logs ".to_owned())).unwrap(); + + assert_eq!(tabs.tab(group).unwrap().name(), Some("Build Logs")); + let json = serde_json::to_string(&tabs).unwrap(); + let mut restored: WorkspaceTabs = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, tabs); + assert_eq!(restored.tab(group).unwrap().name(), Some("Build Logs")); + + restored.rename_tab(group, Some(" ".to_owned())).unwrap(); + + assert_eq!(restored.tab(group).unwrap().name(), None); + assert!(!serde_json::to_string(&restored).unwrap().contains("\"name\"")); + restored.validate().unwrap(); + } + #[test] fn legacy_single_workspace_state_becomes_one_outer_workspace_tab() { let mut legacy = Workspace::new(); diff --git a/docs/acceptance.md b/docs/acceptance.md index 2b4e9130..04b9cb09 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -34,7 +34,9 @@ Chartr Light. Capture and compare: to only the selected group; - live terminal titles changing from their Herdr tab number to `nano`, `htop`, or a detected agent and back when that foreground process exits; every - collapsed pane group title remains its current item count, such as `5 Tabs`; + collapsed pane groups can be renamed from their context menu, blank names + restore the count title, and unnamed groups track their current item count, + such as `5 tabs`; - Zed-style transient pane-body drop highlights: full-content center and half-content left, right, top, and bottom targets, including nearest-edge corner resolution and no split target over a pane's tab bar; diff --git a/docs/adr/0005-spaces-follow-zed-multi-workspace.md b/docs/adr/0005-spaces-follow-zed-multi-workspace.md index 4fc27aed..744a90e8 100644 --- a/docs/adr/0005-spaces-follow-zed-multi-workspace.md +++ b/docs/adr/0005-spaces-follow-zed-multi-workspace.md @@ -60,8 +60,9 @@ animations without changing direct manipulation. Standalone terminal labels are live backend presentation: detected agent, non-shell foreground process, then Herdr's persistent tab label or number. They are refreshed on the same two-second cadence as session discovery and are not -persisted locally. A collapsed pane group is deliberately just its item count, such as `5 Tabs`; -its children retain their individual live labels in the pane-local tab bars. +persisted locally. A collapsed pane group has an optional persisted name and +otherwise uses its item count, such as `5 tabs`; its children retain their +individual live labels in the pane-local tab bars. ## Consequence From 08d1eba1016059262b077d7cb8d8c0695bd8d653 Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 22:18:22 +0800 Subject: [PATCH 018/110] Add chrome view menu and space activation --- README.md | 5 ++-- crates/zeddy/src/app.rs | 17 +++++++++++ crates/zeddy/src/chrome.rs | 4 +++ crates/zeddy/src/chrome/sidebar.rs | 48 +++++++++++++++++++++++++----- crates/zeddy/src/chrome/tabs.rs | 32 +++++++++++++++----- crates/zeddy/src/space.rs | 4 +++ docs/acceptance.md | 10 +++++++ 7 files changed, 103 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4c83b837..1bf634bd 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ use their item count as the title, such as **5 tabs**. ## Settings and persistence -Settings uses one application-wide native window, following Zed: every gear, -the command palette, and `Cmd/Ctrl+,` opens it or focuses the existing instance. +Settings uses one application-wide native window, following Zed: every chrome +view menu, the command palette, and `Cmd/Ctrl+,` opens it or focuses the existing +instance. It closes with the native window controls or `Cmd/Ctrl+W`, and closes when the last workspace window closes. The implemented pages are General, Appearance, Terminal, Hotkeys, and Plugins. Changes update every workspace live and are diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 08847f64..c593751b 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -789,6 +789,22 @@ impl Zeddy { fn act(&mut self, action: Action, window: &mut Window, cx: &mut Context) { match action { Action::BeginSpaceDrag { at } => self.space_sorter.press(at), + Action::ActivateSpace { space } => { + if let Some(target) = + self.spaces.iter().find(|candidate| candidate.entity_id() == space).cloned() + { + self.activate(target, window, cx); + } + } + Action::SwitchToTabs => self.settings_set_mode(Mode::Tabs, cx), + Action::SwitchToSidebar => self.settings_set_mode(Mode::Sidebar, cx), + Action::ToggleActiveSpaceOnly => { + let scope = match self.sidebar_scope { + SidebarScope::AllSpaces => SidebarScope::ActiveSpace, + SidebarScope::ActiveSpace => SidebarScope::AllSpaces, + }; + self.settings_set_sidebar_scope(scope, cx); + } Action::OpenSettings => self.open_settings(window, cx), Action::New => { if matches!(self.backend, Backend::Ready) @@ -2940,6 +2956,7 @@ impl Render for Zeddy { &sidebar_spaces, switcher, emit.clone(), + self.sidebar_scope == SidebarScope::ActiveSpace, &self.space_sorter, self.sidebar_width, cx, diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 25f7ad77..24547192 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -54,6 +54,7 @@ pub struct SpaceEntries { /// What the user did to the chrome. #[derive(Debug, Clone, PartialEq)] pub enum Action { + ActivateSpace { space: EntityId }, Select { space: Option, item: ItemId }, Close { space: Option, item: ItemId }, CloseGroup { space: EntityId, tab: WorkspaceTabId }, @@ -64,6 +65,9 @@ pub enum Action { CloseSpace { space: EntityId }, RenameSpace { space: EntityId }, LocateSpace { space: EntityId }, + SwitchToTabs, + SwitchToSidebar, + ToggleActiveSpaceOnly, NewInSpace { space: EntityId }, New, OpenSettings, diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 86fe1123..5857f126 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -428,6 +428,7 @@ pub fn render( spaces: &[SpaceEntries], space_switcher: AnyElement, on: Emit, + active_space_only: bool, sorter: &SpaceSorter, width: f32, cx: &App, @@ -444,6 +445,7 @@ pub fn render( let reduce_motion = cx.reduce_motion(); for (space_index, space) in spaces.iter().enumerate() { let mut contents = Vec::with_capacity(space.entries.len() + 1); + let activate = on.clone(); let add = on.clone(); let actions = on.clone(); let space_id = space.id; @@ -595,6 +597,7 @@ pub fn render( .relative() .w_full() .flex_none() + .cursor_pointer() .p_1() .rounded_md() .border_1() @@ -606,6 +609,9 @@ pub fn render( }) .when(held, |card| card.border_color(colors.drop_target_border).shadow_md()) .when(offset != px(0.), |card| card.top(offset)) + .on_click(move |_, window, cx| { + activate(Action::ActivateSpace { space: space_id }, window, cx) + }) .children(contents); cards.push( div() @@ -631,7 +637,7 @@ pub fn render( .bg(colors.panel_background) .border_r_1() .border_color(colors.border) - .child(header(space_switcher, on.clone())) + .child(header(space_switcher, on.clone(), active_space_only)) .child( v_flex() .id("sessions") @@ -662,8 +668,8 @@ pub fn render( )) } -fn header(space_switcher: AnyElement, on: Emit) -> impl IntoElement { - let settings = on; +fn header(space_switcher: AnyElement, on: Emit, active_space_only: bool) -> impl IntoElement { + let menu_actions = on; h_flex() .h(px(36.)) .px_2() @@ -672,10 +678,38 @@ fn header(space_switcher: AnyElement, on: Emit) -> impl IntoElement { .child(h_flex().min_w_0().flex_1().child(space_switcher)) .child( h_flex().gap_px().child( - IconButton::new("open-settings", IconName::Settings) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Settings")) - .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), + PopoverMenu::new("chrome-menu") + .trigger_with_tooltip( + IconButton::new("chrome-menu-trigger", IconName::ChevronDown) + .icon_size(IconSize::Small), + Tooltip::text("View options"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let switch = menu_actions.clone(); + let toggle_scope = menu_actions.clone(); + let settings = menu_actions.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Switch to Tabbed mode", None, move |window, cx| { + switch(Action::SwitchToTabs, window, cx) + }) + .toggleable_entry( + "Show only active space", + active_space_only, + IconPosition::End, + None, + move |window, cx| { + toggle_scope(Action::ToggleActiveSpaceOnly, window, cx) + }, + ) + .separator() + .entry( + "Settings", + None, + move |window, cx| settings(Action::OpenSettings, window, cx), + ) + })) + }), ), ) } diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 8c46a7c2..82df91cc 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -5,10 +5,10 @@ //! squeezed in — the dot still carries the state, and the title carries the //! identity. -use gpui::Role; +use gpui::{Anchor, Role}; use ui::{ - ButtonSize, ContextMenu, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*, - right_click_menu, + ButtonSize, ContextMenu, IconButtonShape, PopoverMenu, Tab, TabBar, TabPosition, Tooltip, + prelude::*, right_click_menu, }; use super::Emit; @@ -25,7 +25,7 @@ pub fn render( on: Emit, cx: &App, ) -> impl IntoElement { - let settings = on.clone(); + let menu_actions = on.clone(); let active_index = entries.iter().position(|entry| entry.selected); TabBar::new("workspace-tabs") @@ -37,10 +37,26 @@ pub fn render( ) .end_child(new_item) .end_child( - IconButton::new("open-settings", IconName::Settings) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Settings")) - .on_click(move |_, window, cx| settings(Action::OpenSettings, window, cx)), + PopoverMenu::new("chrome-menu") + .trigger_with_tooltip( + IconButton::new("chrome-menu-trigger", IconName::ChevronDown) + .icon_size(IconSize::Small), + Tooltip::text("View options"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let switch = menu_actions.clone(); + let settings = menu_actions.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Switch to Sidebar mode", None, move |window, cx| { + switch(Action::SwitchToSidebar, window, cx) + }) + .separator() + .entry("Settings", None, move |window, cx| { + settings(Action::OpenSettings, window, cx) + }) + })) + }), ) } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 88cbbdaf..d106d786 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -549,6 +549,7 @@ impl Space { } Action::Close { item, .. } => self.close_item(item, cx), Action::New + | Action::ActivateSpace { .. } | Action::NewInSpace { .. } | Action::MoveWorkspaceTab { .. } | Action::CloseGroup { .. } @@ -557,6 +558,9 @@ impl Space { | Action::CloseSpace { .. } | Action::RenameSpace { .. } | Action::LocateSpace { .. } + | Action::SwitchToTabs + | Action::SwitchToSidebar + | Action::ToggleActiveSpaceOnly | Action::BeginSpaceDrag { .. } | Action::OpenSettings => {} } diff --git a/docs/acceptance.md b/docs/acceptance.md index 04b9cb09..5a35c202 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -58,6 +58,12 @@ directional focus, move-to-existing-pane, join, Settings singleton focus, native `Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. Close the last workspace and confirm Settings closes too. +Open the chevron menu in both chrome modes. Sidebar mode offers `Switch to +Tabbed mode`, a trailing-checkmarked `Show only active space` toggle, a separator, +and `Settings`; tabbed mode offers `Switch to Sidebar mode`, a separator, and +`Settings`. Switching presentation or sidebar scope updates immediately and +survives relaunch. + For tab dragging, exercise each pane-body center and edge target, both corner choices, before and after insertion on existing tabs, trailing-strip append, movement between panes, and movement of the last source tab. Confirm the source @@ -69,6 +75,10 @@ items; tab headers must remain visible and draggable throughout. Clicking inside a web plugin must activate its pane, and its native child view must yield during a drag so neither the tab preview nor drop highlight is obscured. +In the All Spaces sidebar, clicking anywhere on a space card activates that +space and returns keyboard focus to its workspace. Its session rows still select +their specific tabs, and its buttons retain their own actions. + Create five standalone tabs in one space. Move tabs 4 and 5 into tab 3, split tab 4 to the right, and leave tabs 1 and 2 standalone. Both sidebar and tabbed chrome must show exactly three outer entries: tab 1, tab 2, and one three-item From 99f94550d34782c92ad1c63e5f1072ae1e0586bf Mon Sep 17 00:00:00 2001 From: John Goh Date: Tue, 1 Sep 2026 22:38:53 +0800 Subject: [PATCH 019/110] Improve tab strip layout --- crates/zeddy/src/app.rs | 2 +- crates/zeddy/src/chrome.rs | 8 +++++++- crates/zeddy/src/chrome/tabs.rs | 36 ++++++++++++++++++++++++--------- docs/acceptance.md | 6 ++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index c593751b..6cb35288 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -2605,7 +2605,7 @@ impl Zeddy { close_item(Action::Close { space: None, item: close }, window, cx) }), ) - .child(Label::new(item.title()).size(UI_LABEL_DEFAULT).truncate()) + .child(chrome::tab_label(item.title())) .into_any_element(), ) }); diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 24547192..0f5e4f78 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -14,10 +14,16 @@ use crate::{ fonts::UI_LABEL_DEFAULT, workspace::{ItemId, PaneId, WorkspaceTabId}, }; -use gpui::{EntityId, Pixels}; +use gpui::{EntityId, Pixels, SharedString}; use ui::{CommonAnimationExt, prelude::*}; use zeddy_herdr::control::SessionStatus; +const TAB_LABEL_MIN_WIDTH: f32 = 24.; + +pub(crate) fn tab_label(title: impl Into) -> impl IntoElement { + div().min_w(px(TAB_LABEL_MIN_WIDTH)).child(Label::new(title).size(UI_LABEL_DEFAULT).truncate()) +} + /// One row in the sidebar, or one tab in the strip. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 82df91cc..04712186 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -14,8 +14,6 @@ use ui::{ use super::Emit; use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; -use crate::fonts::UI_LABEL_DEFAULT; - const SPACE_SWITCHER_MAX_WIDTH: f32 = 200.; pub fn render( @@ -27,15 +25,35 @@ pub fn render( ) -> impl IntoElement { let menu_actions = on.clone(); let active_index = entries.iter().position(|entry| entry.selected); + let tabs_with_pinned_new_item = h_flex() + .w_full() + .min_w_0() + .h_full() + .child( + h_flex() + .id("workspace-tab-list") + .min_w_0() + .flex_shrink_1() + .overflow_x_scroll() + .children(entries.iter().enumerate().map(|(index, entry)| { + tab(index, entries.len(), active_index, entry, on.clone(), cx) + })), + ) + .child( + h_flex() + .h_full() + .flex_none() + // Collapse this divider onto the last tab's border. + .ml(px(-1.)) + .px(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .child(new_item), + ); TabBar::new("workspace-tabs") .start_child(h_flex().flex_none().max_w(px(SPACE_SWITCHER_MAX_WIDTH)).child(space_switcher)) - .children( - entries.iter().enumerate().map(|(index, entry)| { - tab(index, entries.len(), active_index, entry, on.clone(), cx) - }), - ) - .end_child(new_item) + .child(tabs_with_pinned_new_item) .end_child( PopoverMenu::new("chrome-menu") .trigger_with_tooltip( @@ -167,7 +185,7 @@ fn tab( cx, )) .end_slot::(close_slot) - .child(Label::new(entry.title.clone()).size(UI_LABEL_DEFAULT).truncate()); + .child(super::tab_label(entry.title.clone())); if grouped { right_click_menu(format!("group-tab-menu-{space:?}-{}", close_tab.get())) diff --git a/docs/acceptance.md b/docs/acceptance.md index 5a35c202..8726aeed 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -64,6 +64,12 @@ and `Settings`; tabbed mode offers `Switch to Sidebar mode`, a separator, and `Settings`. Switching presentation or sidebar scope updates immediately and survives relaunch. +In tabbed mode, the `+` control follows the last outer tab while they fit. When +the tabs overflow, only the tabs scroll: `+` pins beside their right edge, and +the chevron view-menu control remains pinned at the far right. The padded `+` +cell retains a left divider against the scrolling tabs. Every tab retains its +minimum clickable width, including pane-local tabs within grouped workspaces. + For tab dragging, exercise each pane-body center and edge target, both corner choices, before and after insertion on existing tabs, trailing-strip append, movement between panes, and movement of the last source tab. Confirm the source From 8d4b2960662e4ddc3e19e312821732c2648adabd Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 01:37:00 +0800 Subject: [PATCH 020/110] Refine active control styling --- crates/zeddy/src/app.rs | 8 +- crates/zeddy/src/chrome/sidebar.rs | 4 +- crates/zeddy/src/chrome/tabs.rs | 5 +- crates/zeddy/src/components.rs | 162 +++++++++++++++++++++++++++- crates/zeddy/src/settings.rs | 33 +++++- crates/zeddy/src/settings_window.rs | 121 +++++++++++++-------- 6 files changed, 276 insertions(+), 57 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 6cb35288..24418314 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -18,9 +18,8 @@ use gpui::{ PathPromptOptions, Role, }; use ui::{ - Banner, ButtonLike, ButtonSize, ContextMenu, IconButtonShape, IconPosition, ListItem, - ListItemSpacing, PopoverMenu, Severity, Tab, TabBar, TabPosition, TintColor, Tooltip, - prelude::*, + Banner, ButtonLike, ButtonSize, IconButtonShape, IconPosition, ListItem, ListItemSpacing, + PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, prelude::*, }; use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; use zeddy_plugin::{InstanceContext, manifest::Multiplicity}; @@ -29,6 +28,7 @@ use zeddy_plugin_host::{Catalog, FileBroker, PaneSource, Paths, SettingsSource}; use crate::{ actions, chrome::{self, Action, DraggedItem, Entry, SpaceEntries, dragged_item_preview}, + components::ContextMenu, fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, item::PluginItem, keys, @@ -1641,7 +1641,7 @@ impl Zeddy { ButtonLike::new("space-switcher-trigger") .aria_label("Current space") .aria_value(current.clone()) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .selected_style(ButtonStyle::Filled) .child(div().min_w_0().max_w(px(148.)).child(Label::new(current).truncate())) .child( Icon::new(IconName::ChevronUpDown) diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 5857f126..97a47c70 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -13,7 +13,7 @@ use gpui::{ Anchor, Bounds, EntityId, MouseButton, Pixels, Point, Rems, Role, ScrollHandle, deferred, point, px, transparent_black, }; -use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*, right_click_menu}; +use ui::{PopoverMenu, Tooltip, prelude::*, right_click_menu}; use super::Emit; @@ -21,7 +21,7 @@ use super::{ Action, DraggedItem, DraggedSidebar, DraggedSpace, Entry, SpaceEntries, dragged_item_preview, status_indicator, }; -use crate::components::{SelectionRowBackgrounds, selection_list, selection_row}; +use crate::components::{ContextMenu, SelectionRowBackgrounds, selection_list, selection_row}; use crate::fonts::{UI_LABEL_DEFAULT, UI_LABEL_SMALL}; use crate::settings::sidebar_theme_colors; diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 04712186..47a647a3 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -7,13 +7,14 @@ use gpui::{Anchor, Role}; use ui::{ - ButtonSize, ContextMenu, IconButtonShape, PopoverMenu, Tab, TabBar, TabPosition, Tooltip, - prelude::*, right_click_menu, + ButtonSize, IconButtonShape, PopoverMenu, Tab, TabBar, TabPosition, Tooltip, prelude::*, + right_click_menu, }; use super::Emit; use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; +use crate::components::ContextMenu; const SPACE_SWITCHER_MAX_WIDTH: f32 = 200.; pub fn render( diff --git a/crates/zeddy/src/components.rs b/crates/zeddy/src/components.rs index 56037a7b..2d12d8fb 100644 --- a/crates/zeddy/src/components.rs +++ b/crates/zeddy/src/components.rs @@ -4,10 +4,78 @@ //! shared across features belong here. use gpui::{ - AnyElement, App, ClickEvent, Div, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, - Role, SharedString, Window, px, relative, + Action, AnyElement, App, ClickEvent, Context, Div, ElementId, Entity, Hsla, IntoElement, + ParentElement, RenderOnce, Role, SharedString, Window, px, relative, }; -use ui::{DynamicSpacing, prelude::*}; +use ui::{ButtonSize, ContextMenu as UiContextMenu, DynamicSpacing, IconPosition, prelude::*}; + +/// Chartr's shared context-menu builder. +/// +/// Zed's menu rows are flush by default. This wrapper inserts a small, +/// non-selectable gap between adjacent actions while leaving separators and +/// headers as distinct group boundaries. +pub struct ContextMenu { + inner: UiContextMenu, + has_item_in_group: bool, +} + +impl ContextMenu { + pub fn build( + window: &mut Window, + cx: &mut App, + build: impl FnOnce(Self, &mut Window, &mut Context) -> Self, + ) -> Entity { + UiContextMenu::build(window, cx, |menu, window, cx| { + build(Self { inner: menu, has_item_in_group: false }, window, cx).inner + }) + } + + fn before_item(mut self) -> Self { + if self.has_item_in_group { + self.inner = self.inner.custom_row(|_, _| div().h_1().into_any_element()); + } + self.has_item_in_group = true; + self + } + + pub fn entry( + self, + label: impl Into, + action: Option>, + handler: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let mut this = self.before_item(); + this.inner = this.inner.entry(label, action, handler); + this + } + + pub fn toggleable_entry( + self, + label: impl Into, + toggled: bool, + position: IconPosition, + action: Option>, + handler: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let mut this = self.before_item(); + this.inner = this.inner.toggleable_entry(label, toggled, position, action, handler); + this + } + + pub fn separator(mut self) -> Self { + self.inner = self.inner.separator(); + self.has_item_in_group = false; + self + } + + pub fn header(mut self, title: impl Into) -> Self { + self.inner = self.inner.header(title); + self.has_item_in_group = false; + self + } +} + +impl FluentBuilder for ContextMenu {} /// A vertical collection of selectable rows. The inter-row gap is part of the /// collection rather than any individual row, so adjacent state backgrounds @@ -24,6 +92,94 @@ pub fn selection_row(id: impl Into, selected: bool) -> SelectionRow { SelectionRow::new(id, selected) } +/// One mutually exclusive choice inside a [`SegmentedControl`]. +pub struct SegmentedControlOption { + id: ElementId, + label: SharedString, + selected: bool, + on_click: Box, +} + +impl SegmentedControlOption { + pub fn new( + id: impl Into, + label: impl Into, + selected: bool, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + Self { id: id.into(), label: label.into(), selected, on_click: Box::new(on_click) } + } +} + +/// A compact radio-like control whose options share one outline and are split +/// by dividers. Selection uses the theme's neutral element surface instead of +/// its semantic accent tint so it remains balanced across light and dark +/// themes. +#[derive(IntoElement)] +pub struct SegmentedControl { + label: SharedString, + options: Vec, + disabled: bool, +} + +impl SegmentedControl { + pub fn new( + label: impl Into, + options: impl IntoIterator, + ) -> Self { + Self { label: label.into(), options: options.into_iter().collect(), disabled: false } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl RenderOnce for SegmentedControl { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let option_count = self.options.len(); + let colors = cx.theme().colors(); + let border = colors.border.opacity(0.8); + + h_flex() + .id(self.label.clone()) + .role(Role::RadioGroup) + .aria_label(self.label) + .rounded_md() + .overflow_hidden() + .border_1() + .border_color(border) + .when(self.disabled, |control| control.opacity(0.5)) + .children(self.options.into_iter().enumerate().map(|(index, option)| { + let selected = option.selected; + h_flex() + .id(option.id) + .role(Role::RadioButton) + .aria_selected(selected) + .h(ButtonSize::Default.rems()) + .px_3() + .when(index + 1 < option_count, |item| item.border_r_1().border_color(border)) + .when(selected, |item| item.bg(colors.ghost_element_selected)) + .when(!selected && !self.disabled, |item| { + item.hover(|style| style.bg(colors.ghost_element_hover)) + .active(|style| style.bg(colors.ghost_element_active)) + }) + .when_else( + self.disabled, + |item| item.cursor_not_allowed(), + |item| item.cursor_pointer().on_click(option.on_click), + ) + .child(Label::new(option.label).size(LabelSize::Small).when( + !selected, + |label| { + label.color(if self.disabled { Color::Disabled } else { Color::Muted }) + }, + )) + })) + } +} + /// Optional state surfaces for a selection row embedded on a custom ground. #[derive(Debug, Clone, Copy)] pub struct SelectionRowBackgrounds { diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index d51d517f..3b675214 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -837,12 +837,15 @@ fn catalog_theme(source: &Theme, palette: ThemePalette) -> Theme { colors.text_muted = muted; colors.text_placeholder = muted; colors.text_disabled = quiet; - colors.text_accent = accent; + // Selected controls should read through surface contrast, not a saturated + // blue foreground. Semantic accents remain available to links, focus + // rings, and status colors below. + colors.text_accent = text; colors.icon = text; colors.icon_muted = muted; colors.icon_placeholder = muted; colors.icon_disabled = quiet; - colors.icon_accent = accent; + colors.icon_accent = text; colors.title_bar_background = sidebar; colors.title_bar_inactive_background = card; colors.toolbar_background = sidebar; @@ -894,6 +897,8 @@ fn chartr_dark(source: &Theme) -> Theme { let colors = &mut dark.styles.colors; let border = gpui::rgb(0x505866).into(); let border_variant = gpui::rgb(0x414956).into(); + colors.text_accent = colors.text; + colors.icon_accent = colors.icon; colors.border = border; colors.border_variant = border_variant; colors.pane_group_border = border; @@ -912,7 +917,7 @@ fn chartr_light(dark: &Theme) -> Theme { let surface = gpui::rgb(0xffffff).into(); let raised = gpui::rgb(0xf1f3f5).into(); let hover = gpui::rgb(0xe8ebef).into(); - let selected = gpui::rgb(0xdce6f5).into(); + let selected = gpui::rgb(0xdfe3e8).into(); let border = gpui::rgb(0xd4d8de).into(); let text = gpui::rgb(0x24272d).into(); let muted = gpui::rgb(0x66707d).into(); @@ -933,10 +938,12 @@ fn chartr_light(dark: &Theme) -> Theme { colors.panel_indent_guide = border; colors.scrollbar_track_border = border; colors.text = text; + colors.text_accent = text; colors.text_muted = muted; colors.text_placeholder = muted; colors.text_disabled = muted; colors.icon = text; + colors.icon_accent = text; colors.icon_muted = muted; colors.icon_placeholder = muted; colors.icon_disabled = muted; @@ -1028,6 +1035,26 @@ mod tests { }); } + #[gpui::test] + fn active_control_foregrounds_are_neutral_across_the_theme_catalog(cx: &mut TestAppContext) { + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + init_themes(&ResolvedSettings::default(), cx); + let registry = ThemeRegistry::global(cx); + + for name in THEME_PALETTES + .map(|palette| palette.name) + .into_iter() + .chain([CHARTR_DARK, CHARTR_LIGHT]) + { + let theme = registry.get(name).unwrap(); + let colors = &theme.styles.colors; + assert_eq!(colors.text_accent, colors.text, "{name} has tinted active text"); + assert_eq!(colors.icon_accent, colors.icon, "{name} has tinted active icons"); + } + }); + } + #[test] fn a_missing_file_resolves_to_fixed_chartr_dark() { let scratch = tempfile::tempdir().unwrap(); diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index 55cbc4b0..f4988d5f 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -11,14 +11,15 @@ use gpui::{ WindowHandle, WindowOptions, actions, px, size, }; use ui::{ - Banner, Button, ColumnWidthConfig, ContextMenu, DropdownMenu, DropdownStyle, Icon, IconButton, - PopoverMenu, RedistributableColumnsState, Severity, Table, TableResizeBehavior, Tooltip, - prelude::*, + Banner, Button, ColumnWidthConfig, DropdownMenu, DropdownStyle, Icon, IconButton, PopoverMenu, + RedistributableColumnsState, Severity, Table, TableResizeBehavior, Tooltip, prelude::*, }; use crate::{ app::Zeddy, - components::{selection_list, selection_row}, + components::{ + ContextMenu, SegmentedControl, SegmentedControlOption, selection_list, selection_row, + }, fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, keymap::{KeymapAction, KeymapStore}, mode::Mode, @@ -537,12 +538,26 @@ impl SettingsWindow { let mode = mode.unwrap_or_default(); let sidebar_scope = sidebar_scope.unwrap_or_default(); let toggle = cx.listener(move |this, _, _, cx| this.set_terminate_on_exit(!terminate, cx)); - let use_sidebar = cx.listener(|this, _, _, cx| this.set_mode(Mode::Sidebar, cx)); - let use_tabs = cx.listener(|this, _, _, cx| this.set_mode(Mode::Tabs, cx)); - let show_all = - cx.listener(|this, _, _, cx| this.set_sidebar_scope(SidebarScope::AllSpaces, cx)); - let show_active = - cx.listener(|this, _, _, cx| this.set_sidebar_scope(SidebarScope::ActiveSpace, cx)); + let use_sidebar = cx.listener(move |this, _, _, cx| { + if runtime_available { + this.set_mode(Mode::Sidebar, cx); + } + }); + let use_tabs = cx.listener(move |this, _, _, cx| { + if runtime_available { + this.set_mode(Mode::Tabs, cx); + } + }); + let show_all = cx.listener(move |this, _, _, cx| { + if runtime_available { + this.set_sidebar_scope(SidebarScope::AllSpaces, cx); + } + }); + let show_active = cx.listener(move |this, _, _, cx| { + if runtime_available { + this.set_sidebar_scope(SidebarScope::ActiveSpace, cx); + } + }); v_flex() .gap_4() .child(Label::new("Chartr").size(UI_LABEL_LARGE)) @@ -573,6 +588,8 @@ impl SettingsWindow { if terminate { "On" } else { "Off" }, ) .toggle_state(terminate) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(toggle), ), ) @@ -589,20 +606,24 @@ impl SettingsWindow { ), ) .child( - h_flex() - .gap_1() - .child( - Button::new("presentation-sidebar", "Sidebar") - .disabled(!runtime_available) - .toggle_state(mode == Mode::Sidebar) - .on_click(use_sidebar), - ) - .child( - Button::new("presentation-tabs", "Tabbed") - .disabled(!runtime_available) - .toggle_state(mode == Mode::Tabs) - .on_click(use_tabs), - ), + SegmentedControl::new( + "Session list presentation", + [ + SegmentedControlOption::new( + "presentation-sidebar", + "Sidebar", + mode == Mode::Sidebar, + use_sidebar, + ), + SegmentedControlOption::new( + "presentation-tabs", + "Tabbed", + mode == Mode::Tabs, + use_tabs, + ), + ], + ) + .disabled(!runtime_available), ), ) .child(setting_label("Sidebar")) @@ -618,20 +639,24 @@ impl SettingsWindow { ), ) .child( - h_flex() - .gap_1() - .child( - Button::new("sidebar-all-spaces", "All spaces") - .disabled(!runtime_available) - .toggle_state(sidebar_scope == SidebarScope::AllSpaces) - .on_click(show_all), - ) - .child( - Button::new("sidebar-active-space", "Active space only") - .disabled(!runtime_available) - .toggle_state(sidebar_scope == SidebarScope::ActiveSpace) - .on_click(show_active), - ), + SegmentedControl::new( + "Spaces shown in the sidebar", + [ + SegmentedControlOption::new( + "sidebar-all-spaces", + "All spaces", + sidebar_scope == SidebarScope::AllSpaces, + show_all, + ), + SegmentedControlOption::new( + "sidebar-active-space", + "Active space only", + sidebar_scope == SidebarScope::ActiveSpace, + show_active, + ), + ], + ) + .disabled(!runtime_available), ), ) .into_any_element() @@ -756,11 +781,15 @@ impl SettingsWindow { .child( Button::new("theme-fixed", "Fixed") .toggle_state(mode == ThemeMode::Fixed) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(fixed_mode), ) .child( Button::new("theme-system", "Match system") .toggle_state(mode == ThemeMode::System) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(system_mode), ), ) @@ -839,6 +868,8 @@ impl SettingsWindow { .child( Button::new("reduce-motion", if reduce_motion { "On" } else { "Off" }) .toggle_state(reduce_motion) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(toggle_reduce_motion), ), ) @@ -953,6 +984,8 @@ impl SettingsWindow { }, ) .toggle_state(recording == Some(action)) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(capture) .into_any_element(), ]) @@ -1051,6 +1084,8 @@ impl SettingsWindow { ) .disabled(!origin_available) .toggle_state(configured.unsafe_filesystem) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(change) }); let configure = has_settings.then(|| { @@ -1086,6 +1121,8 @@ impl SettingsWindow { ) .disabled(!origin_available) .toggle_state(enabled) + .selected_style(ButtonStyle::Filled) + .selected_label_color(Color::Default) .on_click(toggle), ), ) @@ -1179,9 +1216,10 @@ impl Render for SettingsWindow { .min_h_0() .child( v_flex() - .w(px(176.)) + .w(px(240.)) .h_full() .py_3() + .px_1() .border_r_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().surface_background) @@ -1192,10 +1230,7 @@ impl Render for SettingsWindow { .weight(FontWeight::SEMIBOLD), ), ) - .child(div().px_3().py_1().child( - Label::new("Options").size(UI_LABEL_SMALL).color(Color::Muted), - )) - .child(selection_list().px_1().children(navigation)), + .child(selection_list().px_2().children(navigation)), ) .child( div() From 720a9c8cbb925eb6b8ff07f602532385f51bc8c8 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 02:42:20 +0800 Subject: [PATCH 021/110] Standardize settings page layouts --- crates/zeddy/src/settings_window.rs | 974 +++++++++++++++++----------- crates/zeddy/src/text_input.rs | 62 +- 2 files changed, 632 insertions(+), 404 deletions(-) diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index f4988d5f..7bca9fb8 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -6,13 +6,14 @@ //! runtime state (the backend and plugin catalog). use gpui::{ - Anchor, AnyView, App, Bounds, Context, DefiniteLength, Entity, FocusHandle, Focusable, - FontWeight, KeyBinding, PathPromptOptions, Render, Role, WeakEntity, Window, WindowBounds, - WindowHandle, WindowOptions, actions, px, size, + Anchor, AnyView, App, Bounds, ClickEvent, Context, DefiniteLength, ElementId, Entity, + FocusHandle, Focusable, FontWeight, Hsla, KeyBinding, PathPromptOptions, Render, Role, + SharedString, TextAlign, WeakEntity, Window, WindowBounds, WindowHandle, WindowOptions, + actions, px, size, }; use ui::{ - Banner, Button, ColumnWidthConfig, DropdownMenu, DropdownStyle, Icon, IconButton, PopoverMenu, - RedistributableColumnsState, Severity, Table, TableResizeBehavior, Tooltip, prelude::*, + Banner, Button, ButtonSize, ColumnWidthConfig, DropdownMenu, DropdownStyle, Icon, PopoverMenu, + RedistributableColumnsState, Severity, Switch, Table, TableResizeBehavior, Tooltip, prelude::*, }; use crate::{ @@ -28,10 +29,15 @@ use crate::{ self, AppearanceContent, GeneralContent, ResolvedSettings, SettingsPage, SettingsStore, TerminalContent, ThemeMode, }, + text_input::{InputEvent, TextInput}, }; actions!(settings_window, [Close]); +const SETTINGS_WINDOW_MIN_WIDTH: f32 = 720.; +const SETTINGS_CONTROL_COLUMN_WIDTH: f32 = 200.; +const SETTINGS_FIELD_VERTICAL_PADDING: f32 = 16.; + #[derive(Clone, Copy)] enum ThemeTarget { Fixed, @@ -94,7 +100,7 @@ fn open_with_origin( is_movable: true, kind: gpui::WindowKind::Normal, window_background: cx.theme().window_background_appearance(), - window_min_size: Some(size(px(640.), px(420.))), + window_min_size: Some(size(px(SETTINGS_WINDOW_MIN_WIDTH), px(420.))), ..Default::default() }, |window, cx| { @@ -116,6 +122,8 @@ pub struct SettingsWindow { plugin_settings: Option<(String, AnyView)>, recording_keymap: Option, keymap_restart_required: bool, + ui_font_size_input: Entity, + terminal_font_size_input: Entity, hotkey_widths: Entity, focus: FocusHandle, problem: Option, @@ -128,8 +136,53 @@ impl SettingsWindow { window: &mut Window, cx: &mut Context, ) -> Self { - cx.observe_global_in::(window, |_, _, cx| cx.notify()).detach(); + let resolved = cx.global::().resolved().clone(); + let ui_font_size = format_number(resolved.ui_font_size); + let ui_font_size_input = cx.new(|cx| { + let mut input = TextInput::new("Interface font size", cx); + input.set_text(ui_font_size, false, cx); + input.set_text_align(TextAlign::Center, cx); + input + }); + let terminal_font_size = format_number(resolved.terminal_font_size); + let terminal_font_size_input = cx.new(|cx| { + let mut input = TextInput::new("Terminal font size", cx); + input.set_text(terminal_font_size, false, cx); + input.set_text_align(TextAlign::Center, cx); + input + }); + cx.observe_global_in::(window, |this, window, cx| { + this.sync_font_size_inputs(window, cx); + cx.notify(); + }) + .detach(); cx.observe_global_in::(window, |_, _, cx| cx.notify()).detach(); + cx.subscribe(&ui_font_size_input, |this, input, _: &InputEvent, cx| { + if let Ok(value) = input.read(cx).text().parse::() + && value.is_finite() + && (8. ..=32.).contains(&value) + { + this.set_ui_font_size(value, cx); + } + }) + .detach(); + cx.subscribe(&terminal_font_size_input, |this, input, _: &InputEvent, cx| { + if let Ok(value) = input.read(cx).text().parse::() + && value.is_finite() + && (8. ..=72.).contains(&value) + { + this.set_terminal_font_size(value, cx); + } + }) + .detach(); + cx.on_focus_out(&ui_font_size_input.focus_handle(cx), window, |this, _, _, cx| { + this.commit_ui_font_size_input(cx) + }) + .detach(); + cx.on_focus_out(&terminal_font_size_input.focus_handle(cx), window, |this, _, _, cx| { + this.commit_terminal_font_size_input(cx) + }) + .detach(); cx.on_window_closed(|cx, _| { if let Some(settings) = cx.windows().into_iter().find_map(|window| window.downcast::()) @@ -146,6 +199,8 @@ impl SettingsWindow { plugin_settings: None, recording_keymap: None, keymap_restart_required: false, + ui_font_size_input, + terminal_font_size_input, hotkey_widths: cx.new(|_| { RedistributableColumnsState::new( 2, @@ -259,12 +314,15 @@ impl SettingsWindow { ); } - fn adjust_ui_font_size(&mut self, delta: f32, cx: &mut Context) { - let current = cx.global::().resolved().ui_font_size; + fn set_ui_font_size(&mut self, size: f32, cx: &mut Context) { + let size = size.clamp(8., 32.); + if cx.global::().resolved().ui_font_size == size { + return; + } self.update_settings( move |content| { content.appearance.get_or_insert_with(AppearanceContent::default).ui_font_size = - Some((current + delta).clamp(8., 32.)); + Some(size); }, false, true, @@ -272,6 +330,11 @@ impl SettingsWindow { ); } + fn adjust_ui_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = cx.global::().resolved().ui_font_size; + self.set_ui_font_size(current + delta, cx); + } + fn set_terminal_font(&mut self, family: String, cx: &mut Context) { self.update_settings( move |content| { @@ -284,12 +347,15 @@ impl SettingsWindow { ); } - fn adjust_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { - let current = cx.global::().resolved().terminal_font_size; + fn set_terminal_font_size(&mut self, size: f32, cx: &mut Context) { + let size = size.clamp(8., 72.); + if cx.global::().resolved().terminal_font_size == size { + return; + } self.update_settings( move |content| { content.terminal.get_or_insert_with(TerminalContent::default).font_size = - Some((current + delta).clamp(8., 72.)); + Some(size); }, false, false, @@ -297,6 +363,51 @@ impl SettingsWindow { ); } + fn adjust_terminal_font_size(&mut self, delta: f32, cx: &mut Context) { + let current = cx.global::().resolved().terminal_font_size; + self.set_terminal_font_size(current + delta, cx); + } + + fn commit_ui_font_size_input(&mut self, cx: &mut Context) { + let current = self.settings(cx).ui_font_size; + let value = self + .ui_font_size_input + .read(cx) + .text() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.clamp(8., 32.)) + .unwrap_or(current); + self.set_ui_font_size(value, cx); + sync_number_text(&self.ui_font_size_input, value, cx); + } + + fn commit_terminal_font_size_input(&mut self, cx: &mut Context) { + let current = self.settings(cx).terminal_font_size; + let value = self + .terminal_font_size_input + .read(cx) + .text() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.clamp(8., 72.)) + .unwrap_or(current); + self.set_terminal_font_size(value, cx); + sync_number_text(&self.terminal_font_size_input, value, cx); + } + + fn sync_font_size_inputs(&mut self, window: &mut Window, cx: &mut Context) { + let settings = self.settings(cx); + if !self.ui_font_size_input.focus_handle(cx).is_focused(window) { + sync_number_text(&self.ui_font_size_input, settings.ui_font_size, cx); + } + if !self.terminal_font_size_input.focus_handle(cx).is_focused(window) { + sync_number_text(&self.terminal_font_size_input, settings.terminal_font_size, cx); + } + } + fn pick_free_sessions_directory(&mut self, cx: &mut Context) { let chosen = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -537,7 +648,7 @@ impl SettingsWindow { let runtime_available = mode.is_some() && sidebar_scope.is_some(); let mode = mode.unwrap_or_default(); let sidebar_scope = sidebar_scope.unwrap_or_default(); - let toggle = cx.listener(move |this, _, _, cx| this.set_terminate_on_exit(!terminate, cx)); + let terminate_setting = cx.weak_entity(); let use_sidebar = cx.listener(move |this, _, _, cx| { if runtime_available { this.set_mode(Mode::Sidebar, cx); @@ -558,108 +669,71 @@ impl SettingsWindow { this.set_sidebar_scope(SidebarScope::ActiveSpace, cx); } }); - v_flex() - .gap_4() - .child(Label::new("Chartr").size(UI_LABEL_LARGE)) - .child( - Label::new(format!( - "Version {} · configuration namespace chartr-zeddy", - env!("CARGO_PKG_VERSION") - )) - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ) - .child( - h_flex() - .justify_between() - .gap_4() - .child( - v_flex() - .child(Label::new("Terminate sessions on exit").size(UI_LABEL_DEFAULT)) - .child( - Label::new("Normal app exit detaches and leaves sessions running.") - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ), - ) - .child( - Button::new( - "terminate-sessions-on-exit", - if terminate { "On" } else { "Off" }, + settings_fields( + vec![ + setting_field( + "Terminate sessions on exit", + "End running sessions when Chartr exits instead of leaving them detached.", + Switch::new("terminate-sessions-on-exit", terminate.into()) + .tab_index(0isize) + .aria_label("Terminate sessions on exit") + .aria_description( + "End running sessions when Chartr exits instead of leaving them detached.", ) - .toggle_state(terminate) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(toggle), - ), - ) - .child(setting_label("Presentation")) - .child( - h_flex() - .justify_between() - .gap_4() - .child( - v_flex().child(Label::new("Session list").size(UI_LABEL_DEFAULT)).child( - Label::new("Show sessions in a sidebar or a tab strip.") - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ), + .on_click(move |state, _, cx| { + let terminate = state.selected(); + let _ = terminate_setting.update(cx, |this, cx| { + this.set_terminate_on_exit(terminate, cx) + }); + }), + ), + setting_field( + "Session list", + "Choose where sessions appear in the workspace.", + SegmentedControl::new( + "Session list presentation", + [ + SegmentedControlOption::new( + "presentation-sidebar", + "Sidebar", + mode == Mode::Sidebar, + use_sidebar, + ), + SegmentedControlOption::new( + "presentation-tabs", + "Tabbed", + mode == Mode::Tabs, + use_tabs, + ), + ], ) - .child( - SegmentedControl::new( - "Session list presentation", - [ - SegmentedControlOption::new( - "presentation-sidebar", - "Sidebar", - mode == Mode::Sidebar, - use_sidebar, - ), - SegmentedControlOption::new( - "presentation-tabs", - "Tabbed", - mode == Mode::Tabs, - use_tabs, - ), - ], - ) - .disabled(!runtime_available), - ), - ) - .child(setting_label("Sidebar")) - .child( - h_flex() - .justify_between() - .gap_4() - .child( - v_flex().child(Label::new("Spaces shown").size(UI_LABEL_DEFAULT)).child( - Label::new("Show every space or only the currently active space.") - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ), + .disabled(!runtime_available), + ), + setting_field( + "Spaces shown", + "Show every space in the sidebar or only the active one.", + SegmentedControl::new( + "Spaces shown in the sidebar", + [ + SegmentedControlOption::new( + "sidebar-all-spaces", + "All spaces", + sidebar_scope == SidebarScope::AllSpaces, + show_all, + ), + SegmentedControlOption::new( + "sidebar-active-space", + "Active only", + sidebar_scope == SidebarScope::ActiveSpace, + show_active, + ), + ], ) - .child( - SegmentedControl::new( - "Spaces shown in the sidebar", - [ - SegmentedControlOption::new( - "sidebar-all-spaces", - "All spaces", - sidebar_scope == SidebarScope::AllSpaces, - show_all, - ), - SegmentedControlOption::new( - "sidebar-active-space", - "Active space only", - sidebar_scope == SidebarScope::ActiveSpace, - show_active, - ), - ], - ) - .disabled(!runtime_available), - ), - ) - .into_any_element() + .disabled(!runtime_available), + ), + ], + cx.theme().colors().border_variant, + ) } fn theme_dropdown( @@ -740,8 +814,7 @@ impl SettingsWindow { let fixed_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::Fixed, cx)); let system_mode = cx.listener(|this, _, _, cx| this.set_theme_mode(ThemeMode::System, cx)); let reduce_motion = settings.reduce_motion; - let toggle_reduce_motion = - cx.listener(move |this, _, _, cx| this.set_reduce_motion(!reduce_motion, cx)); + let reduce_motion_setting = cx.weak_entity(); let font = cx.weak_entity(); let smaller = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(-1., cx)); let larger = cx.listener(|this, _, _, cx| this.adjust_ui_font_size(1., cx)); @@ -772,108 +845,100 @@ impl SettingsWindow { window, cx, ); - v_flex() - .gap_3() - .child(setting_label("Theme mode")) - .child( - h_flex() - .gap_1() - .child( - Button::new("theme-fixed", "Fixed") - .toggle_state(mode == ThemeMode::Fixed) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(fixed_mode), - ) - .child( - Button::new("theme-system", "Match system") - .toggle_state(mode == ThemeMode::System) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(system_mode), - ), + let font_picker = PopoverMenu::new("ui-font-menu") + .trigger( + Button::new("ui-font-family", settings.ui_font_family) + .end_icon(Icon::new(IconName::ChevronDown)), ) - .when(mode == ThemeMode::Fixed, |view| { - view.child(setting_label("Theme")).child(fixed_picker) - }) - .when(mode == ThemeMode::System, |view| { - view.child( - h_flex() - .gap_6() - .child( - v_flex() - .gap_1() - .child(setting_label("Light theme")) - .child(light_picker), - ) - .child( - v_flex().gap_1().child(setting_label("Dark theme")).child(dark_picker), - ), - ) - }) - .child(setting_label("Interface font")) - .child( - h_flex() - .gap_1() - .child( - PopoverMenu::new("ui-font-menu") - .trigger( - Button::new("ui-font-family", settings.ui_font_family) - .end_icon(Icon::new(IconName::ChevronDown)), - ) - .anchor(Anchor::BottomLeft) - .menu(move |window, cx| { - let font = font.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - ["IBM Plex Sans", ".ZedSans", "System UI"].into_iter().fold( - menu, - |menu, family| { - let set = font.clone(); - menu.entry(family, None, move |_, cx| { - let _ = set.update(cx, |this, cx| { - this.set_ui_font(family.to_owned(), cx) - }); - }) - }, - ) - })) - }), - ) - .child( - IconButton::new("ui-font-smaller", IconName::Dash) - .tooltip(Tooltip::text("Decrease interface font size")) - .on_click(smaller), - ) - .child( - Label::new(format!("{} px", settings.ui_font_size)).size(UI_LABEL_DEFAULT), + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + ["IBM Plex Sans", ".ZedSans", "System UI"].into_iter().fold( + menu, + |menu, family| { + let set = font.clone(); + menu.entry(family, None, move |_, cx| { + let _ = set + .update(cx, |this, cx| this.set_ui_font(family.to_owned(), cx)); + }) + }, ) - .child( - IconButton::new("ui-font-larger", IconName::Plus) - .tooltip(Tooltip::text("Increase interface font size")) - .on_click(larger), + })) + }); + let font_size = number_field( + "ui-font-size", + "Interface font size", + "Adjust the size of interface text.", + self.ui_font_size_input.clone(), + smaller, + larger, + cx, + ); + + let mut fields = vec![setting_field( + "Theme mode", + "Use one theme at all times or follow the system appearance.", + SegmentedControl::new( + "Theme mode", + [ + SegmentedControlOption::new( + "theme-fixed", + "Fixed", + mode == ThemeMode::Fixed, + fixed_mode, ), - ) - .child(setting_label("Motion")) - .child( - h_flex() - .justify_between() - .gap_4() - .child( - v_flex().child(Label::new("Reduce motion").size(UI_LABEL_DEFAULT)).child( - Label::new("Disable movement animations when space cards are sorted.") - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ), - ) - .child( - Button::new("reduce-motion", if reduce_motion { "On" } else { "Off" }) - .toggle_state(reduce_motion) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(toggle_reduce_motion), + SegmentedControlOption::new( + "theme-system", + "System", + mode == ThemeMode::System, + system_mode, ), - ) - .into_any_element() + ], + ), + )]; + match mode { + ThemeMode::Fixed => fields.push(setting_field( + "Theme", + "Choose the theme used throughout the interface.", + fixed_picker, + )), + ThemeMode::System => { + fields.push(setting_field( + "Light theme", + "Choose the theme used while the system is in light mode.", + light_picker, + )); + fields.push(setting_field( + "Dark theme", + "Choose the theme used while the system is in dark mode.", + dark_picker, + )); + } + } + fields.extend([ + setting_field( + "Font family", + "Choose the typeface used throughout the interface.", + font_picker, + ), + setting_field("Font size", "Adjust the size of interface text.", font_size), + setting_field( + "Reduce motion", + "Disable movement animations when space cards are sorted.", + Switch::new("reduce-motion", reduce_motion.into()) + .tab_index(0isize) + .aria_label("Reduce motion") + .aria_description("Disable movement animations when space cards are sorted.") + .on_click(move |state, _, cx| { + let reduce_motion = state.selected(); + let _ = reduce_motion_setting + .update(cx, |this, cx| this.set_reduce_motion(reduce_motion, cx)); + }), + ), + ]); + + settings_fields(fields, cx.theme().colors().border_variant) } fn terminal_page(&mut self, cx: &mut Context) -> AnyElement { @@ -885,78 +950,79 @@ impl SettingsWindow { let retry = cx.listener(|this, _, _, cx| this.retry_backend(cx)); let restart = cx.listener(|this, _, _, cx| this.restart_backend(cx)); let runtime_available = self.original.upgrade().is_some(); - v_flex() - .gap_3() - .child(setting_label("Terminal font")) - .child( - h_flex() - .gap_1() - .child( - PopoverMenu::new("terminal-font-menu") - .trigger( - Button::new("terminal-font-family", settings.terminal_font_family) - .end_icon(Icon::new(IconName::ChevronDown)), - ) - .anchor(Anchor::BottomLeft) - .menu(move |window, cx| { - let font = font.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - ["IBM Plex Mono", "Lilex", ".ZedMono"].into_iter().fold( - menu, - |menu, family| { - let set = font.clone(); - menu.entry(family, None, move |_, cx| { - let _ = set.update(cx, |this, cx| { - this.set_terminal_font(family.to_owned(), cx) - }); - }) - }, - ) - })) - }), - ) - .child( - IconButton::new("terminal-font-smaller", IconName::Dash) - .tooltip(Tooltip::text("Decrease terminal font size")) - .on_click(smaller), - ) - .child( - Label::new(format!("{} px", settings.terminal_font_size)) - .size(UI_LABEL_DEFAULT), - ) - .child( - IconButton::new("terminal-font-larger", IconName::Plus) - .tooltip(Tooltip::text("Increase terminal font size")) - .on_click(larger), - ), - ) - .child(setting_label("Free sessions directory")) - .child( - Button::new( - "choose-free-sessions-directory", - settings.ad_hoc_directory.as_ref().map_or_else( - || "Home directory".to_owned(), - |path| path.display().to_string(), - ), - ) - .on_click(choose_directory), - ) - .child(setting_value("Backend", self.backend_label(cx))) - .child( - h_flex() - .gap_1() - .child( - Button::new("settings-retry-backend", "Retry") - .disabled(!runtime_available) - .on_click(retry), - ) - .child( - Button::new("settings-restart-backend", "Restart Backend") - .disabled(!runtime_available) - .on_click(restart), - ), + let font_picker = PopoverMenu::new("terminal-font-menu") + .trigger( + Button::new("terminal-font-family", settings.terminal_font_family) + .end_icon(Icon::new(IconName::ChevronDown)), ) - .into_any_element() + .anchor(Anchor::BottomLeft) + .menu(move |window, cx| { + let font = font.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + ["IBM Plex Mono", "Lilex", ".ZedMono"].into_iter().fold(menu, |menu, family| { + let set = font.clone(); + menu.entry(family, None, move |_, cx| { + let _ = set.update(cx, |this, cx| { + this.set_terminal_font(family.to_owned(), cx) + }); + }) + }) + })) + }); + let font_size = number_field( + "terminal-font-size", + "Terminal font size", + "Adjust the size of terminal text.", + self.terminal_font_size_input.clone(), + smaller, + larger, + cx, + ); + let directory = settings + .ad_hoc_directory + .as_ref() + .map_or_else(|| "Home directory".to_owned(), |path| path.display().to_string()); + + settings_fields( + vec![ + setting_field( + "Font family", + "Choose the typeface used in terminal sessions.", + font_picker, + ), + setting_field("Font size", "Adjust the size of terminal text.", font_size), + setting_field( + "Free sessions directory", + "Choose the working directory used when a Free session starts.", + Button::new("choose-free-sessions-directory", directory) + .start_icon(Icon::new(IconName::FolderOpen).color(Color::Muted)) + .end_icon(Icon::new(IconName::ChevronRight).color(Color::Muted)) + .truncate(true) + .tooltip(Tooltip::text("Choose Free sessions directory")) + .on_click(choose_directory), + ), + setting_field( + "Backend status", + "Show the session backend connected to this workspace.", + Label::new(self.backend_label(cx)).size(UI_LABEL_DEFAULT), + ), + setting_field( + "Retry connection", + "Try to reconnect after a backend connection failure.", + Button::new("settings-retry-backend", "Retry") + .disabled(!runtime_available) + .on_click(retry), + ), + setting_field( + "Restart backend", + "Stop and start the backend process for this workspace.", + Button::new("settings-restart-backend", "Restart") + .disabled(!runtime_available) + .on_click(restart), + ), + ], + cx.theme().colors().border_variant, + ) } fn hotkeys_page(&mut self, cx: &mut Context) -> AnyElement { @@ -1028,109 +1094,96 @@ impl SettingsWindow { .map(|origin| origin.read(cx).settings_plugins()) .unwrap_or_default(); let settings = self.settings(cx); - let rows: Vec<_> = descriptors - .into_iter() - .map(|descriptor| { - let manifest = descriptor.manifest; - let enabled = descriptor.enabled; - let has_settings = descriptor.has_settings; - let id = manifest.id.clone(); - let control_id = id.clone(); - let configured = settings.plugin(&id); - let toggle = cx.listener(move |this, _, _, cx| { - this.set_plugin_enabled(id.clone(), !enabled, cx) - }); - let trust = match manifest.kind { - zeddy_plugin::manifest::Kind::Native => { - "Native — fully trusted code".to_owned() - } - zeddy_plugin::manifest::Kind::Web => { - let project = match manifest.permissions.project_files { - zeddy_plugin::manifest::ProjectAccess::None => "no project files", - zeddy_plugin::manifest::ProjectAccess::Read => "read project files", - zeddy_plugin::manifest::ProjectAccess::ReadWrite => { - "read/write project files" - } - }; - let mut grants = vec![project.to_owned()]; - if !manifest.permissions.network.is_empty() { - grants.push(format!( - "network: {}", - manifest.permissions.network.join(", ") - )); - } - if manifest.permissions.process { - grants.push("process actions".to_owned()); - } - if manifest.permissions.session { - grants.push("bound-session actions".to_owned()); + let mut fields = Vec::new(); + for descriptor in descriptors { + let manifest = descriptor.manifest; + let name = manifest.name.clone(); + let id = manifest.id.clone(); + let enabled = descriptor.enabled; + let has_settings = descriptor.has_settings; + let unsafe_filesystem = settings.plugin(&id).unsafe_filesystem; + let is_web = manifest.kind == zeddy_plugin::manifest::Kind::Web; + let access = match manifest.kind { + zeddy_plugin::manifest::Kind::Native => { + format!("Identifier: {id}. Runs as fully trusted native code.") + } + zeddy_plugin::manifest::Kind::Web => { + let project = match manifest.permissions.project_files { + zeddy_plugin::manifest::ProjectAccess::None => "no project files", + zeddy_plugin::manifest::ProjectAccess::Read => "read project files", + zeddy_plugin::manifest::ProjectAccess::ReadWrite => { + "read and write project files" } - format!("Web — {}", grants.join(" · ")) + }; + let mut grants = vec![project.to_owned()]; + if !manifest.permissions.network.is_empty() { + grants.push(format!( + "network access to {}", + manifest.permissions.network.join(", ") + )); } - }; - let unsafe_control = - (manifest.kind == zeddy_plugin::manifest::Kind::Web).then(|| { - let id = manifest.id.clone(); - let change = cx.listener(move |this, _, _, cx| { - this.set_plugin_unsafe(id.clone(), !configured.unsafe_filesystem, cx) - }); - Button::new( - format!("plugin-unsafe-{}", manifest.id), - if configured.unsafe_filesystem { - "Unsafe filesystem granted" - } else { - "Grant unsafe filesystem" - }, - ) - .disabled(!origin_available) - .toggle_state(configured.unsafe_filesystem) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(change) + if manifest.permissions.process { + grants.push("process actions".to_owned()); + } + if manifest.permissions.session { + grants.push("bound-session actions".to_owned()); + } + format!("Identifier: {id}. Access: {}.", grants.join(", ")) + } + }; + + let enabled_name = format!("{name} — Enabled"); + let enabled_description = access; + let enabled_id = id.clone(); + let enabled_setting = cx.weak_entity(); + let enabled_control = Switch::new(format!("plugin-enabled-{id}"), enabled.into()) + .disabled(!origin_available) + .tab_index(0isize) + .aria_label(enabled_name.clone()) + .aria_description(enabled_description.clone()) + .on_click(move |state, _, cx| { + let enabled = state.selected(); + let _ = enabled_setting.update(cx, |this, cx| { + this.set_plugin_enabled(enabled_id.clone(), enabled, cx) }); - let configure = has_settings.then(|| { - let id = manifest.id.clone(); - Button::new(format!("plugin-settings-{}", manifest.id), "Configure") + }); + fields.push(setting_field(enabled_name, enabled_description, enabled_control)); + + if has_settings { + let settings_id = id.clone(); + fields.push(setting_field( + format!("{name} — Configuration"), + "Open this plugin's own settings.", + Button::new(format!("plugin-settings-{id}"), "Configure") .disabled(!origin_available) .on_click(cx.listener(move |this, _, window, cx| { - this.open_plugin_settings(id.clone(), window, cx) - })) - }); - v_flex() - .gap_2() - .p_3() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - h_flex() - .justify_between() - .child( - v_flex() - .child(Label::new(manifest.name).size(UI_LABEL_DEFAULT)) - .child( - Label::new(manifest.id) - .size(UI_LABEL_SMALL) - .color(Color::Muted), - ), - ) - .child( - Button::new( - format!("plugin-enabled-{control_id}"), - if enabled { "Enabled" } else { "Disabled" }, - ) - .disabled(!origin_available) - .toggle_state(enabled) - .selected_style(ButtonStyle::Filled) - .selected_label_color(Color::Default) - .on_click(toggle), - ), - ) - .child(Label::new(trust).size(UI_LABEL_SMALL).color(Color::Muted)) - .when_some(configure, |row, control| row.child(control)) - .when_some(unsafe_control, |row, control| row.child(control)) - }) - .collect(); + this.open_plugin_settings(settings_id.clone(), window, cx) + })), + )); + } + + if is_web { + let unsafe_name = format!("{name} — Unsafe filesystem access"); + let unsafe_description = + "Allow access to files outside the plugin's declared project permissions."; + let unsafe_id = id.clone(); + let unsafe_setting = cx.weak_entity(); + let unsafe_control = + Switch::new(format!("plugin-unsafe-{id}"), unsafe_filesystem.into()) + .disabled(!origin_available) + .tab_index(0isize) + .aria_label(unsafe_name.clone()) + .aria_description(unsafe_description) + .on_click(move |state, _, cx| { + let enabled = state.selected(); + let _ = unsafe_setting.update(cx, |this, cx| { + this.set_plugin_unsafe(unsafe_id.clone(), enabled, cx) + }); + }); + fields.push(setting_field(unsafe_name, unsafe_description, unsafe_control)); + } + } + let has_fields = !fields.is_empty(); let rejected: Vec<_> = rejected .into_iter() .map(|rejected| { @@ -1142,7 +1195,7 @@ impl SettingsWindow { .collect(); let _ = window; v_flex() - .gap_2() + .gap_4() .when(!origin_available, |view| { view.child( Banner::new().child( @@ -1153,10 +1206,12 @@ impl SettingsWindow { ), ) }) - .when(rows.is_empty() && rejected.is_empty(), |view| { + .when(!has_fields && rejected.is_empty(), |view| { view.child(Label::new("No plugins installed.").color(Color::Muted)) }) - .children(rows) + .when(has_fields, |view| { + view.child(settings_fields(fields, cx.theme().colors().border_variant)) + }) .children(rejected) .into_any_element() } @@ -1267,16 +1322,123 @@ impl Render for SettingsWindow { } } -fn setting_label(label: &'static str) -> AnyElement { - Label::new(label).size(UI_LABEL_DEFAULT).color(Color::Muted).into_any_element() +fn format_number(value: f32) -> String { + value.to_string() +} + +fn sync_number_text(input: &Entity, value: f32, cx: &mut Context) { + let value = format_number(value); + if input.read(cx).text() != value { + input.update(cx, |input, cx| input.set_text(value, false, cx)); + } +} + +fn number_field( + id: &'static str, + label: &'static str, + description: &'static str, + input: Entity, + decrement: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + increment: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + let id: ElementId = id.into(); + let colors = cx.theme().colors(); + let border = colors.border_variant; + let background = colors.surface_background; + let hover = colors.element_hover; + let focus = input.focus_handle(cx); + + let decrement = h_flex() + .id((id.clone(), "decrement")) + .role(Role::Button) + .aria_label("Decrement") + .tab_index(0isize) + .w(px(32.)) + .h_full() + .justify_center() + .cursor_pointer() + .rounded_l_sm() + .border_1() + .border_color(border) + .bg(background) + .hover(|style| style.bg(hover)) + .on_click(decrement) + .child(Icon::new(IconName::Dash).size(IconSize::Small)); + let increment = h_flex() + .id((id.clone(), "increment")) + .role(Role::Button) + .aria_label("Increment") + .tab_index(0isize) + .w(px(32.)) + .h_full() + .justify_center() + .cursor_pointer() + .rounded_r_sm() + .border_1() + .border_color(border) + .bg(background) + .hover(|style| style.bg(hover)) + .on_click(increment) + .child(Icon::new(IconName::Plus).size(IconSize::Small)); + + h_flex() + .id(id) + .role(Role::SpinButton) + .aria_label(label) + .aria_description(description) + .h(ButtonSize::Default.rems()) + .gap_1() + .child(decrement) + .child( + h_flex() + .w(px(64.)) + .h_full() + .px_2() + .border_y_1() + .border_color(border) + .bg(background) + .track_focus(&focus) + .in_focus(|field| field.border_1().border_color(colors.border_focused)) + .child(input), + ) + .child(increment) + .into_any_element() +} + +fn settings_fields(fields: Vec, separator: Hsla) -> AnyElement { + v_flex() + .w_full() + .children(fields.into_iter().enumerate().map(|(index, field)| { + div() + .w_full() + .when(index > 0, |row| row.border_t_1().border_color(separator)) + .child(field) + })) + .into_any_element() } -fn setting_value(label: &'static str, value: String) -> AnyElement { +fn setting_field( + name: impl Into, + description: impl Into, + control: impl IntoElement, +) -> AnyElement { h_flex() - .justify_between() - .gap_4() - .child(Label::new(label).size(UI_LABEL_DEFAULT).color(Color::Muted)) - .child(Label::new(value).size(UI_LABEL_DEFAULT)) + .w_full() + .items_start() + .gap_6() + .py(px(SETTINGS_FIELD_VERTICAL_PADDING)) + .child( + v_flex() + .min_w_0() + .flex_1() + .gap_1() + .child(Label::new(name).size(UI_LABEL_DEFAULT)) + .child(Label::new(description).size(UI_LABEL_SMALL).color(Color::Muted)), + ) + .child( + h_flex().w(px(SETTINGS_CONTROL_COLUMN_WIDTH)).flex_none().justify_end().child(control), + ) .into_any_element() } @@ -1375,4 +1537,32 @@ mod tests { assert!(cx.global::().resolved().terminate_sessions_on_exit); }); } + + #[gpui::test] + fn font_size_inputs_update_the_application_settings(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| open_with_origin(None, WeakEntity::new_invalid(), cx)); + cx.run_until_parked(); + let settings = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap(); + let (ui_input, terminal_input) = cx.update(|cx| { + let settings = settings.read(cx).unwrap(); + (settings.ui_font_size_input.clone(), settings.terminal_font_size_input.clone()) + }); + + cx.update(|cx| { + ui_input.update(cx, |input, cx| input.set_text("18", false, cx)); + terminal_input.update(cx, |input, cx| input.set_text("16", false, cx)); + }); + cx.run_until_parked(); + + cx.update(|cx| { + let resolved = cx.global::().resolved(); + assert_eq!(resolved.ui_font_size, 18.); + assert_eq!(resolved.terminal_font_size, 16.); + }); + } } diff --git a/crates/zeddy/src/text_input.rs b/crates/zeddy/src/text_input.rs index 6c147ad8..c6d542c0 100644 --- a/crates/zeddy/src/text_input.rs +++ b/crates/zeddy/src/text_input.rs @@ -12,8 +12,8 @@ use gpui::{ App, Bounds, ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, KeyBinding, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - ShapedLine, SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window, actions, - fill, point, prelude::*, px, relative, size, + ShapedLine, SharedString, Style, TextAlign, TextRun, UTF16Selection, UnderlineStyle, Window, + actions, fill, point, prelude::*, px, relative, size, }; use ui::prelude::*; use unicode_segmentation::UnicodeSegmentation as _; @@ -156,6 +156,8 @@ pub struct TextInput { last_layout: Option, last_bounds: Option>, scroll_x: Pixels, + alignment_offset: Pixels, + text_align: TextAlign, is_selecting: bool, undo: Vec, redo: Vec, @@ -173,6 +175,8 @@ impl TextInput { last_layout: None, last_bounds: None, scroll_x: px(0.), + alignment_offset: px(0.), + text_align: TextAlign::Left, is_selecting: false, undo: Vec::new(), redo: Vec::new(), @@ -183,6 +187,11 @@ impl TextInput { &self.content } + pub fn set_text_align(&mut self, text_align: TextAlign, cx: &mut Context) { + self.text_align = text_align; + cx.notify(); + } + pub fn set_text( &mut self, text: impl Into, @@ -576,7 +585,7 @@ impl TextInput { if position.x >= bounds.right() { return self.content.len(); } - line.closest_index_for_x(position.x - bounds.left() + self.scroll_x) + line.closest_index_for_x(position.x - bounds.left() - self.alignment_offset + self.scroll_x) } fn offset_from_utf16(&self, offset: usize) -> usize { @@ -719,8 +728,15 @@ impl EntityInputHandler for TextInput { let line = self.last_layout.as_ref()?; let range = self.range_from_utf16(&range); Some(Bounds::from_corners( - point(bounds.left() + line.x_for_index(range.start) - self.scroll_x, bounds.top()), - point(bounds.left() + line.x_for_index(range.end) - self.scroll_x, bounds.bottom()), + point( + bounds.left() + self.alignment_offset + line.x_for_index(range.start) + - self.scroll_x, + bounds.top(), + ), + point( + bounds.left() + self.alignment_offset + line.x_for_index(range.end) - self.scroll_x, + bounds.bottom(), + ), )) } @@ -732,7 +748,8 @@ impl EntityInputHandler for TextInput { ) -> Option { let bounds = self.last_bounds?; let line = self.last_layout.as_ref()?; - let index = line.index_for_x(point.x - bounds.left() + self.scroll_x)?; + let index = + line.index_for_x(point.x - bounds.left() - self.alignment_offset + self.scroll_x)?; Some(self.offset_to_utf16(index)) } } @@ -746,6 +763,7 @@ struct PrepaintState { cursor: Option, selection: Option, scroll_x: Pixels, + alignment_offset: Pixels, } impl IntoElement for TextElement { @@ -793,7 +811,15 @@ impl Element for TextElement { ) -> PrepaintState { let style = window.text_style(); let colors = cx.theme().colors(); - let (display_text, text_color, selected_range, cursor, marked_range, previous_scroll) = { + let ( + display_text, + text_color, + selected_range, + cursor, + marked_range, + previous_scroll, + text_align, + ) = { let input = self.input.read(cx); let display = if input.content.is_empty() { (input.placeholder.clone(), colors.text_muted) @@ -807,6 +833,7 @@ impl Element for TextElement { input.cursor_offset(), input.marked_range.clone(), input.scroll_x, + input.text_align, ) }; @@ -849,13 +876,19 @@ impl Element for TextElement { } else if cursor_x > scroll_x + viewport - px(2.) { scroll_x = (cursor_x - viewport + px(2.)).min(max_scroll); } + let remaining = (viewport - line.width).max(px(0.)); + let alignment_offset = match text_align { + TextAlign::Left => px(0.), + TextAlign::Center => remaining / 2., + TextAlign::Right => remaining, + }; let (selection, cursor) = if selected_range.is_empty() { ( None, Some(fill( Bounds::new( - point(bounds.left() + cursor_x - scroll_x, bounds.top()), + point(bounds.left() + alignment_offset + cursor_x - scroll_x, bounds.top()), size(px(1.), bounds.size.height), ), cx.theme().players().local().cursor, @@ -866,11 +899,15 @@ impl Element for TextElement { Some(fill( Bounds::from_corners( point( - bounds.left() + line.x_for_index(selected_range.start) - scroll_x, + bounds.left() + + alignment_offset + + line.x_for_index(selected_range.start) + - scroll_x, bounds.top(), ), point( - bounds.left() + line.x_for_index(selected_range.end) - scroll_x, + bounds.left() + alignment_offset + line.x_for_index(selected_range.end) + - scroll_x, bounds.bottom(), ), ), @@ -879,7 +916,7 @@ impl Element for TextElement { None, ) }; - PrepaintState { line: Some(line), cursor, selection, scroll_x } + PrepaintState { line: Some(line), cursor, selection, scroll_x, alignment_offset } } fn paint( @@ -899,7 +936,7 @@ impl Element for TextElement { } let line = state.line.take().expect("prepaint shaped the input line"); let _ = line.paint( - point(bounds.left() - state.scroll_x, bounds.top()), + point(bounds.left() + state.alignment_offset - state.scroll_x, bounds.top()), window.line_height(), gpui::TextAlign::Left, None, @@ -915,6 +952,7 @@ impl Element for TextElement { input.last_layout = Some(line); input.last_bounds = Some(bounds); input.scroll_x = state.scroll_x; + input.alignment_offset = state.alignment_offset; }); } } From a9f6a490fcf843c907c9ff1f7e23be0d91bf2f85 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 03:02:50 +0800 Subject: [PATCH 022/110] Add pane-scoped new session buttons --- crates/zeddy/src/app.rs | 65 ++++++++++++++++++++++++++++----------- crates/zeddy/src/space.rs | 52 ++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 24418314..c62f3bb7 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -1693,6 +1693,30 @@ impl Zeddy { .into_any_element() } + fn pane_new_item_button( + &self, + tab_id: WorkspaceTabId, + pane_id: LayoutPaneId, + weak: &gpui::WeakEntity, + ) -> AnyElement { + let button_id = format!("new-item-pane-{}-{}", tab_id.get(), pane_id.get()); + let start = weak.clone(); + IconButton::new(button_id, IconName::Plus) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("New session in this pane")) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + let _ = start.update(cx, |this, cx| { + if matches!(this.backend, Backend::Ready) + && let Some(space) = this.active.clone() + { + space.update(cx, |space, cx| space.start_session_in(tab_id, pane_id, cx)); + } + }); + }) + .into_any_element() + } + fn web_plugin_focus_handler( space: Entity, cx: &Context, @@ -2477,24 +2501,28 @@ impl Zeddy { let close = weak.clone(); TabBar::new(format!("workspace-tab-{}-pane-{}-empty", tab_id.get(), pane_id.get())) .end_child( - IconButton::new( - format!("close-empty-pane-{}-{}", tab_id.get(), pane_id.get()), - IconName::Close, - ) - .shape(IconButtonShape::Square) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall) - .aria_label("Close Empty Pane") - .tooltip(Tooltip::text("Close Empty Pane")) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - let _ = close.update(cx, |this, cx| { - if let Some(space) = this.active.clone() { - space.update(cx, |space, _| space.remove_empty_pane(tab_id, pane_id)); - } - cx.notify(); - }); - }), + h_flex().gap_1().child(self.pane_new_item_button(tab_id, pane_id, weak)).child( + IconButton::new( + format!("close-empty-pane-{}-{}", tab_id.get(), pane_id.get()), + IconName::Close, + ) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .aria_label("Close Empty Pane") + .tooltip(Tooltip::text("Close Empty Pane")) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + let _ = close.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| { + space.remove_empty_pane(tab_id, pane_id) + }); + } + cx.notify(); + }); + }), + ), ) .into_any_element() } @@ -2643,6 +2671,7 @@ impl Zeddy { TabBar::new(format!("workspace-tab-{}-pane-{}-tabs", tab_id.get(), pane_id.get())) .children(tabs) .child(tab_bar_drop_target) + .end_child(self.pane_new_item_button(tab_id, pane_id, weak)) .into_any_element() } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index d106d786..1476b5e9 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -762,6 +762,23 @@ impl Space { } pub fn start_session(&mut self, cx: &mut Context) { + self.start_session_at(None, cx); + } + + pub fn start_session_in( + &mut self, + tab: WorkspaceTabId, + pane: crate::workspace::PaneId, + cx: &mut Context, + ) { + self.start_session_at(Some((tab, pane)), cx); + } + + fn start_session_at( + &mut self, + destination: Option<(WorkspaceTabId, crate::workspace::PaneId)>, + cx: &mut Context, + ) { if self.starting { return; } @@ -798,7 +815,11 @@ impl Space { this.starting = false; match result { Ok(session) => { - this.insert_session(session); + if let Some((tab, pane)) = destination { + this.insert_session_in(session, tab, pane); + } else { + this.insert_session(session); + } this.problem = None; } Err(error) => this.problem = Some(error.to_string()), @@ -813,6 +834,35 @@ impl Space { self.insert_session_with_id(session, None); } + fn insert_session_in( + &mut self, + session: Session, + tab: WorkspaceTabId, + pane: crate::workspace::PaneId, + ) { + self.workspace = Some(session.info.workspace.clone()); + let backend_id = session.id().clone(); + if self.sessions.contains_key(&backend_id) { + return; + } + + let id = self.layout.alloc_item(); + self.items.insert(id, Item::Session(SessionItem::new(session))); + let placed = self + .layout + .workspace_mut(tab) + .ok_or(crate::workspace::ModelError::WorkspaceTabNotFound(tab)) + .and_then(|layout| layout.add_item(id, Some(pane), None)); + if placed.is_ok() { + let _ = self.layout.activate_tab(tab); + } else if let Err(error) = self.layout.push_standalone(id) { + self.items.remove(&id); + self.problem = Some(error.to_string()); + return; + } + self.sessions.insert(backend_id, id); + } + fn insert_session_with_id(&mut self, session: Session, restored: Option) { self.workspace = Some(session.info.workspace.clone()); let backend_id = session.id().clone(); From 4d66f97444fb9a86b42df92d6bd21b40c17caba1 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 03:04:57 +0800 Subject: [PATCH 023/110] Add custom macOS title bar --- crates/zeddy/src/app.rs | 15 +++++- crates/zeddy/src/main.rs | 7 ++- crates/zeddy/src/settings_window.rs | 14 ++++-- crates/zeddy/src/title_bar.rs | 72 +++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 crates/zeddy/src/title_bar.rs diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index c62f3bb7..7defecfb 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -149,6 +149,7 @@ pub struct Zeddy { window_bounds: Option, state: Option, last_persisted: Option, + title_bar: Entity, focus: FocusHandle, problem: Option, } @@ -164,6 +165,7 @@ impl Zeddy { .detach(); let command_palette_input = cx.new(|cx| TextInput::new("Type a command…", cx)); let rename_input = cx.new(|cx| TextInput::new("Type a name…", cx)); + let title_bar = cx.new(|_| crate::title_bar::TitleBar::new("workspace-title-bar")); cx.subscribe(&command_palette_input, |this, input, _: &InputEvent, cx| { this.command_palette_query = input.read(cx).text().to_owned(); this.command_palette_selected = 0; @@ -216,6 +218,7 @@ impl Zeddy { window_bounds: saved.window.bounds, state, last_persisted: saved_json, + title_bar, focus: cx.focus_handle(), problem: Some(state_problem.unwrap_or_else(|| error.to_string())), }; @@ -332,6 +335,7 @@ impl Zeddy { window_bounds: saved.window.bounds, state, last_persisted: saved_json, + title_bar, focus: cx.focus_handle(), problem: state_problem.or(registry_problem), }; @@ -2980,7 +2984,9 @@ impl Render for Zeddy { let body = match self.mode { Mode::Sidebar => h_flex() - .size_full() + .w_full() + .flex_1() + .min_h_0() .child(chrome::sidebar::render( &sidebar_spaces, switcher, @@ -2993,7 +2999,9 @@ impl Render for Zeddy { .child(workspace) .into_any_element(), Mode::Tabs => v_flex() - .size_full() + .w_full() + .flex_1() + .min_h_0() .child(chrome::tabs::render(chrome_entries, switcher, new_item, emit, cx)) .child(workspace) .into_any_element(), @@ -3016,6 +3024,8 @@ impl Render for Zeddy { "Chartr" }) .size_full() + .flex() + .flex_col() .font(ui_font) .text_size(UI_TEXT_DEFAULT) .bg(background) @@ -3100,6 +3110,7 @@ impl Render for Zeddy { this.toggle_command_palette(window, cx) })) .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) + .child(self.title_bar.clone()) .child(body) .children(command_palette) .children(rename_space) diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 53dbc248..68a11d5d 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -25,6 +25,7 @@ mod space; mod spaces; mod terminal; mod text_input; +mod title_bar; mod web_plugin; mod workspace; @@ -97,10 +98,8 @@ fn main() { let window = cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(gpui::TitlebarOptions { - title: Some("Chartr".into()), - ..Default::default() - }), + titlebar: Some(title_bar::options("Chartr")), + app_owns_titlebar_drag: title_bar::app_owns_drag(), ..Default::default() }, |window, cx| { diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index 7bca9fb8..8f735354 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -91,10 +91,8 @@ fn open_with_origin( let opened = cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(gpui::TitlebarOptions { - title: Some("Chartr — Settings".into()), - ..Default::default() - }), + titlebar: Some(crate::title_bar::options("Chartr — Settings")), + app_owns_titlebar_drag: crate::title_bar::app_owns_drag(), focus: true, show: true, is_movable: true, @@ -125,6 +123,7 @@ pub struct SettingsWindow { ui_font_size_input: Entity, terminal_font_size_input: Entity, hotkey_widths: Entity, + title_bar: Entity, focus: FocusHandle, problem: Option, } @@ -208,6 +207,7 @@ impl SettingsWindow { vec![TableResizeBehavior::Resizable, TableResizeBehavior::Resizable], ) }), + title_bar: cx.new(|_| crate::title_bar::TitleBar::new("settings-title-bar")), focus: cx.focus_handle(), problem: None, } @@ -1256,6 +1256,8 @@ impl Render for SettingsWindow { .key_context("ChartrSettings") .track_focus(&self.focus) .size_full() + .flex() + .flex_col() .font(ui_font) .text_size(UI_TEXT_DEFAULT) .bg(cx.theme().colors().background) @@ -1265,9 +1267,11 @@ impl Render for SettingsWindow { window.activate_window() })) .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) + .child(self.title_bar.clone()) .child( h_flex() - .size_full() + .w_full() + .flex_1() .min_h_0() .child( v_flex() diff --git a/crates/zeddy/src/title_bar.rs b/crates/zeddy/src/title_bar.rs new file mode 100644 index 00000000..5ffb3ffc --- /dev/null +++ b/crates/zeddy/src/title_bar.rs @@ -0,0 +1,72 @@ +//! The small, app-drawn macOS title bar shared by Chartr windows. + +use gpui::{ElementId, MouseButton, TitlebarOptions, point, px}; +use ui::prelude::*; + +const HEIGHT: f32 = 34.; + +/// Use the native title bar everywhere except macOS, where Chartr draws the +/// background and AppKit keeps responsibility for the traffic-light controls. +pub fn options(fallback_title: &'static str) -> TitlebarOptions { + TitlebarOptions { + title: (!cfg!(target_os = "macos")).then(|| fallback_title.into()), + appears_transparent: cfg!(target_os = "macos"), + traffic_light_position: cfg!(target_os = "macos").then(|| point(px(9.), px(9.))), + } +} + +pub const fn app_owns_drag() -> bool { + cfg!(target_os = "macos") +} + +pub struct TitleBar { + id: ElementId, + should_move: bool, +} + +impl TitleBar { + pub fn new(id: impl Into) -> Self { + Self { id: id.into(), should_move: false } + } +} + +/// Draw a deliberately empty title bar on macOS. The native traffic lights +/// sit above this surface, so the rest of the bar remains one drag target. +impl Render for TitleBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !cfg!(target_os = "macos") { + return div().id(self.id.clone()).into_any_element(); + } + + let colors = cx.theme().colors(); + let background = if window.is_window_active() { + colors.title_bar_background + } else { + colors.title_bar_inactive_background + }; + + h_flex() + .id(self.id.clone()) + .w_full() + .h(px(HEIGHT)) + .flex_none() + .bg(background) + .border_b_1() + .border_color(colors.border) + .on_mouse_down_out(cx.listener(|this, _, _, _| this.should_move = false)) + .on_mouse_up(MouseButton::Left, cx.listener(|this, _, _, _| this.should_move = false)) + .on_mouse_down(MouseButton::Left, cx.listener(|this, _, _, _| this.should_move = true)) + .on_mouse_move(cx.listener(|this, _, window, _| { + if this.should_move { + this.should_move = false; + window.start_window_move(); + } + })) + .on_click(|event, window, _| { + if event.click_count() == 2 { + window.titlebar_double_click(); + } + }) + .into_any_element() + } +} From 17893600708d0855a61b79c09f1540489bfea14b Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 11:09:55 +0800 Subject: [PATCH 024/110] Add terminal scrollback --- .plan/maps/chartr-zeddy-workspace/map.md | 1 - .plan/maps/chartr-zeddy-workspace/spec.md | 9 +- crates/zeddy-herdr/src/control.rs | 21 ++- crates/zeddy-herdr/src/protocol.rs | 33 ++++ crates/zeddy-vt/src/lib.rs | 199 ++++++++++++++++++---- crates/zeddy/src/app.rs | 23 ++- crates/zeddy/src/session.rs | 89 +++++++++- crates/zeddy/src/terminal.rs | 80 ++++++++- crates/zeddy/tests/live_session.rs | 43 +++++ docs/acceptance.md | 11 +- docs/adr/0004-the-vt-core.md | 17 +- 11 files changed, 463 insertions(+), 63 deletions(-) diff --git a/.plan/maps/chartr-zeddy-workspace/map.md b/.plan/maps/chartr-zeddy-workspace/map.md index f987a733..ef703630 100644 --- a/.plan/maps/chartr-zeddy-workspace/map.md +++ b/.plan/maps/chartr-zeddy-workspace/map.md @@ -27,7 +27,6 @@ The settled product contract is recorded in [the specification](./spec.md). ## Out of scope - Windows support, pending a non-Unix Herdr transport. -- Terminal scrollback, pending a real Herdr history source. - Cross-space item movement. - Terminal mirroring, preview tabs, and pinned tabs. - Automatic migration from existing Chartr installations. diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index 2542c588..908d190a 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -166,6 +166,7 @@ configuration automatically. 90. As an existing Chartr user, I want Chartr-zeddy data isolated from older installations, so that the rewrite cannot corrupt or conflict with existing settings. 91. As a Chartr user, I want to drag-sort every sidebar space, including Free sessions and recovered folders, so that the cockpit order matches my workflow and survives relaunch. 92. As an accessibility user, I want Reduce Motion to disable space-sort settling without disabling direct manipulation, so that reordering remains usable with less animation. +93. As a Chartr user, I want wheel and trackpad gestures to move through Herdr's host scrollback, so that output remains reviewable after it leaves the live viewport. ## Implementation Decisions @@ -188,6 +189,11 @@ configuration automatically. refresh: display agent, internal agent, non-shell foreground process, then persistent tab label/number. Exiting a process restores the fallback rather than leaving a stale locally remembered title. +- Terminal wheel deltas are accumulated in row units. The first upward gesture + loads ANSI-styled `pane.read` host history on a background thread and moves a + separate historical VT viewport; live repaint frames remain isolated from + history so they cannot manufacture duplicate or missing rows. New live output + marks a bottomed history snapshot for refresh, and resizing invalidates it. - An opened item entity may appear in only one outer workspace tab, pane, and space. Moving an item removes it from its source before insertion; an emptied outer tab disappears. Cross-space moves are absent. @@ -256,8 +262,6 @@ configuration automatically. command palette, menus, buttons, and shortcuts dispatch the same actions. - The settings catalog contains General, Appearance, Terminal, Hotkeys, and Plugins. Controls are omitted until their behavior exists. -- Scrollback is omitted because the current Herdr frame stream exposes only the - viewport and cannot implement genuine history. - Zed's existing UI components and semantic styles are audited before any local reusable component is introduced. Chartr may compose product-specific views. - `Chartr Light` and `Chartr Dark` are standard semantic theme families. Chartr @@ -374,7 +378,6 @@ configuration automatically. - Cross-space tab movement or duplication. - Terminal cloning or mirrored views. - Preview tabs and pinned tabs. -- Terminal scrollback until Herdr provides a correct history source. - Automatic import or shared configuration with Go Chartr or Chartr-rs. - Multiple operating-system windows; spaces provide independent workspace ownership within the Chartr window. diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs index 0378ef5c..5b8c34a0 100644 --- a/crates/zeddy-herdr/src/control.rs +++ b/crates/zeddy-herdr/src/control.rs @@ -23,8 +23,9 @@ use crate::{ Error, Geometry, Namespace, PaneId, Result, SUPPORTED_HERDR_VERSION, SUPPORTED_PROTOCOL, Sidecar, WorkspaceId, protocol::{ - self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, Pong, Request, Response, - TabCreateParams, TabList, TabListParams, WorkspaceCreateParams, WorkspaceList, + self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, PaneReadEnvelope, + PaneReadParams, Pong, Request, Response, TabCreateParams, TabList, TabListParams, + WorkspaceCreateParams, WorkspaceList, }, stream::Attachment, }; @@ -371,6 +372,22 @@ impl Client { Ok(()) } + /// Styled host scrollback, oldest requested row first and including the + /// live viewport at the bottom. + pub fn history(&self, pane: &PaneId, lines: u32) -> Result { + let read: PaneReadEnvelope = self.call( + "pane.read", + &PaneReadParams { + pane_id: &pane.0, + source: "recent", + lines, + format: "ansi", + strip_ansi: false, + }, + )?; + Ok(read.read.text) + } + /// Attach to a session's byte stream at a given geometry. /// /// The client hands its own sidecar and namespace to the attachment, so the diff --git a/crates/zeddy-herdr/src/protocol.rs b/crates/zeddy-herdr/src/protocol.rs index 7dd29eaa..2db6d33c 100644 --- a/crates/zeddy-herdr/src/protocol.rs +++ b/crates/zeddy-herdr/src/protocol.rs @@ -189,6 +189,29 @@ pub struct PaneCloseParams<'a> { pub pane_id: &'a str, } +#[derive(Debug, Serialize)] +pub struct PaneReadParams<'a> { + pub pane_id: &'a str, + pub source: &'static str, + pub lines: u32, + pub format: &'static str, + pub strip_ansi: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PaneRead { + pub text: String, + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub truncated: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PaneReadEnvelope { + pub read: PaneRead, +} + #[derive(Debug, Deserialize)] pub struct WorkspaceList { #[serde(default)] @@ -275,6 +298,16 @@ mod tests { assert_eq!(parsed.result.expect("result").panes[0].pane_id, "p1"); } + #[test] + fn styled_history_uses_the_pane_read_envelope() { + let raw = r#"{"id":"1","result":{"type":"pane_read","read":{"pane_id":"p1","workspace_id":"w1","tab_id":"t1","source":"recent","format":"ansi","text":"\u001b[31mred","revision":4,"truncated":false}}}"#; + let parsed: Response = serde_json::from_str(raw).expect("parses"); + let read = parsed.result.expect("result").read; + assert_eq!(read.text, "\x1b[31mred"); + assert_eq!(read.revision, 4); + assert!(!read.truncated); + } + #[test] fn stream_messages_are_tagged_by_type() { let raw = r#"{"type":"terminal.frame","bytes":"aGk=","full":true,"seq":0,"width":80,"height":24}"#; diff --git a/crates/zeddy-vt/src/lib.rs b/crates/zeddy-vt/src/lib.rs index 8af68434..b3ee3f7d 100644 --- a/crates/zeddy-vt/src/lib.rs +++ b/crates/zeddy-vt/src/lib.rs @@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex}; use alacritty_terminal::{ event::{Event, EventListener}, - grid::Dimensions, + grid::{Dimensions, Scroll as AlacrittyScroll}, index::{Column, Line, Point}, term::{Config, cell::Flags}, vte::ansi::{Color as AnsiColor, NamedColor, Processor}, @@ -158,25 +158,47 @@ impl EventListener for TitleSink { } } -/// A terminal emulator fed by [`Terminal::feed`]. -pub struct Terminal { +struct Emulation { term: alacritty_terminal::Term, parser: Processor, +} + +impl Emulation { + fn new(size: Size, scrolling_history: usize, title: TitleSink) -> Self { + let config = Config { scrolling_history, ..Config::default() }; + Self { term: alacritty_terminal::Term::new(config, &size, title), parser: Processor::new() } + } +} + +/// The outcome of trying to move the visible viewport. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollResult { + Changed, + NeedsHistory, + Unchanged, +} + +/// A terminal emulator fed by [`Terminal::feed`]. +pub struct Terminal { + live: Emulation, + history: Option, + history_stale: bool, + generation: u64, size: Size, title: TitleSink, } impl Terminal { pub fn new(size: Size) -> Self { - // No scrollback. herdr's frame stream sends the viewport and has no way - // to move it back through history, so a scrollback buffer here would be - // a buffer nothing can ever scroll to. History comes from the control - // plane instead, and is a different rendering. - let config = Config { scrolling_history: 0, ..Config::default() }; let title = TitleSink::default(); Self { - term: alacritty_terminal::Term::new(config, &size, title.clone()), - parser: Processor::new(), + // Repaint frames describe only the live viewport. Letting them + // manufacture local history retains arbitrary repaint artifacts, + // so real history is loaded separately from Herdr's control plane. + live: Emulation::new(size, 0, title.clone()), + history: None, + history_stale: false, + generation: 0, size, title, } @@ -188,7 +210,9 @@ impl Terminal { /// Apply a repaint. Bytes must arrive in the order they were produced. pub fn feed(&mut self, bytes: &[u8]) { - self.parser.advance(&mut self.term, bytes); + self.live.parser.advance(&mut self.live.term, bytes); + self.generation = self.generation.wrapping_add(1); + self.history_stale = self.history.is_some(); } /// Re-run the grid at a new size. @@ -200,35 +224,107 @@ impl Terminal { return; } self.size = size; - self.term.resize(size); + self.live.term.resize(size); + self.history = None; + self.history_stale = false; + self.generation = self.generation.wrapping_add(1); + } + + /// Move through the most recently loaded host scrollback. + pub fn scroll(&mut self, lines: i32) -> ScrollResult { + if lines == 0 { + return ScrollResult::Unchanged; + } + let Some(history) = self.history.as_mut() else { + return if lines > 0 { ScrollResult::NeedsHistory } else { ScrollResult::Unchanged }; + }; + if lines > 0 && self.history_stale && history.term.grid().display_offset() == 0 { + return ScrollResult::NeedsHistory; + } + + let before = history.term.grid().display_offset(); + history.term.scroll_display(AlacrittyScroll::Delta(lines)); + if before != history.term.grid().display_offset() { + ScrollResult::Changed + } else { + ScrollResult::Unchanged + } + } + + /// A token for deciding whether live output arrived during an asynchronous + /// history request. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Replace the historical snapshot with ANSI-styled rows from Herdr, then + /// apply the wheel movement that requested them. + pub fn load_history(&mut self, ansi: &str, lines: i32, requested_at: u64) -> bool { + let mut history = + Emulation::new(self.size, Config::default().scrolling_history, TitleSink::default()); + let ansi = crlf(ansi); + history.parser.advance(&mut history.term, &ansi); + + // This snapshot may have become stale while it was in flight, but it + // is still the answer to the gesture that requested it. Apply that + // gesture once; `scroll` will require a refresh after returning to the + // live viewport. + let before = history.term.grid().display_offset(); + history.term.scroll_display(AlacrittyScroll::Delta(lines)); + let changed = before != history.term.grid().display_offset(); + self.history = Some(history); + self.history_stale = self.generation != requested_at; + changed } /// Copy the current screen out. pub fn screen(&self) -> Screen { - let grid = self.term.grid(); - let mut rows = Vec::with_capacity(self.size.rows as usize); - for line in 0..self.size.rows as i32 { - let mut cells = Vec::with_capacity(self.size.cols as usize); - for column in 0..self.size.cols as usize { - cells.push(convert(&grid[Point::new(Line(line), Column(column))])); - } - rows.push(cells); + let term = self + .history + .as_ref() + .filter(|history| history.term.grid().display_offset() > 0) + .map(|history| &history.term) + .unwrap_or(&self.live.term); + screen(term, self.size, &self.title) + } +} + +fn screen(term: &alacritty_terminal::Term, size: Size, title: &TitleSink) -> Screen { + let grid = term.grid(); + let mode = term.mode(); + let display_offset = i32::try_from(grid.display_offset()).unwrap_or(i32::MAX); + let mut rows = Vec::with_capacity(size.rows as usize); + for line in 0..size.rows as i32 { + let mut cells = Vec::with_capacity(size.cols as usize); + for column in 0..size.cols as usize { + let line = Line(line.saturating_sub(display_offset)); + cells.push(convert(&grid[Point::new(line, Column(column))])); } + rows.push(cells); + } - let cursor = { - let point = grid.cursor.point; - let visible = - self.term.mode().contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); - visible.then(|| Cursor { col: point.column.0 as u16, row: point.line.0.max(0) as u16 }) - }; + let cursor = { + let point = grid.cursor.point; + let visible = mode.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); + let viewport_row = point.line.0.saturating_add(display_offset); + (visible && viewport_row >= 0 && viewport_row < i32::from(size.rows)) + .then(|| Cursor { col: point.column.0 as u16, row: viewport_row as u16 }) + }; - Screen { - size: self.size, - rows, - cursor, - title: self.title.0.lock().expect("title mutex").clone(), + Screen { size, rows, cursor, title: title.0.lock().expect("title mutex").clone() } +} + +fn crlf(text: &str) -> Vec { + let mut bytes = Vec::with_capacity(text.len()); + let mut previous = None; + for byte in text.bytes() { + if byte == b'\n' && previous != Some(b'\r') { + bytes.push(b'\r'); } + bytes.push(byte); + previous = Some(byte); } + bytes } impl std::fmt::Debug for Terminal { @@ -347,6 +443,47 @@ mod tests { assert_eq!(screen.rows[0].len(), 40); } + #[test] + fn host_history_can_move_the_visible_viewport() { + let mut term = Terminal::new(Size::new(20, 3)); + term.feed(b"one\r\ntwo\r\nthree\r\nfour"); + assert_eq!(term.screen().to_text(), "two\nthree\nfour"); + assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); + + let requested_at = term.generation(); + assert!(term.load_history("\x1b[31mone\x1b[0m\ntwo\nthree\nfour", 1, requested_at,)); + let history = term.screen(); + assert_eq!(history.to_text(), "one\ntwo\nthree"); + assert_eq!(history.rows[0][0].fg, Color::Indexed(NamedColor::Red as u8)); + assert_eq!(history.cursor, None, "the live cursor is outside the historical viewport"); + + assert_eq!(term.scroll(-1), ScrollResult::Changed); + assert_eq!(term.screen().to_text(), "two\nthree\nfour"); + assert_eq!(term.scroll(-1), ScrollResult::Unchanged); + } + + #[test] + fn live_output_marks_a_bottomed_history_snapshot_for_refresh() { + let mut term = Terminal::new(Size::new(20, 3)); + let requested_at = term.generation(); + assert!(term.load_history("one\ntwo\nthree\nfour", 1, requested_at)); + assert_eq!(term.scroll(-1), ScrollResult::Changed); + + term.feed(b"new output"); + assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); + } + + #[test] + fn history_loaded_after_live_output_is_already_stale() { + let mut term = Terminal::new(Size::new(20, 3)); + let requested_at = term.generation(); + + term.feed(b"latest\r\n"); + assert!(term.load_history("one\ntwo\nthree\nfour", 1, requested_at)); + assert_eq!(term.scroll(-1), ScrollResult::Changed); + assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); + } + #[test] fn a_zero_sized_grid_is_never_handed_to_the_emulator() { assert_eq!(Size::new(0, 0), Size::new(1, 1)); diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 7defecfb..c81a6fc2 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -3260,6 +3260,8 @@ fn terminal( ) -> impl IntoElement { let theme = cx.theme(); let screen = item.session.screen(); + let fit = item.fit.clone(); + let session = item.session.access(); let colors = screen .rows .iter() @@ -3275,13 +3277,20 @@ fn terminal( cursor: theme.colors().terminal_foreground, }; - v_flex().size_full().p_2().bg(theme.colors().terminal_background).child(TerminalElement::new( - screen, - colors, - appearance, - focused, - item.fit.clone(), - )) + v_flex() + .size_full() + .p_2() + .bg(theme.colors().terminal_background) + .on_scroll_wheel(move |event, window, cx| { + let Some(lines) = fit.wheel_lines(event) else { + return; + }; + if session.scroll(lines) { + window.refresh(); + } + cx.stop_propagation(); + }) + .child(TerminalElement::new(screen, colors, appearance, focused, item.fit.clone())) } fn message(text: &str, cx: &App) -> impl IntoElement { diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index b6943093..2772ee9c 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -16,7 +16,10 @@ //! session producing a thousand repaints a second costs the window one redraw //! per vsync, not a thousand. -use std::sync::{Arc, Mutex}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use futures::channel::mpsc; use zeddy_herdr::{ @@ -24,7 +27,9 @@ use zeddy_herdr::{ control::{self, Client}, stream::{Frame, Input}, }; -use zeddy_vt::{Screen, Size, Terminal}; +use zeddy_vt::{Screen, ScrollResult, Size, Terminal}; + +const HISTORY_LINES: u32 = 10_000; /// A wakeup from a session's reader thread. Carries nothing: the state is in /// the emulator, and the message only says to look at it. @@ -45,6 +50,10 @@ pub struct Session { terminal: Arc>, ended: Arc>>, input: Arc>, + client: Client, + wakeups: mpsc::UnboundedSender, + history_loading: Arc, + pending_scroll: Arc>, size: Size, } @@ -64,6 +73,7 @@ impl Session { let terminal = Arc::new(Mutex::new(Terminal::new(size))); let ended = Arc::new(Mutex::new(None)); + let reader_wakeups = wakeups.clone(); std::thread::Builder::new() .name(format!("zeddy-session-{}", info.id)) @@ -83,17 +93,27 @@ impl Session { // Sent after the frame is applied, so a redraw woken by // this always sees it. A closed receiver means the // window is gone, and so is the reason to keep reading. - if wakeups.unbounded_send(()).is_err() { + if reader_wakeups.unbounded_send(()).is_err() { return; } }; *ended.lock().expect("ended mutex") = Some(outcome); - let _ = wakeups.unbounded_send(()); + let _ = reader_wakeups.unbounded_send(()); } }) .expect("spawn a session reader thread"); - Ok(Self { info, terminal, ended, input: Arc::new(Mutex::new(input)), size }) + Ok(Self { + info, + terminal, + ended, + input: Arc::new(Mutex::new(input)), + client: client.clone(), + wakeups, + history_loading: Arc::new(AtomicBool::new(false)), + pending_scroll: Arc::new(Mutex::new(0)), + size, + }) } pub fn id(&self) -> &PaneId { @@ -146,7 +166,15 @@ impl Session { } pub fn access(&self) -> SessionAccess { - SessionAccess { info: self.info.clone(), input: self.input.clone() } + SessionAccess { + info: self.info.clone(), + input: self.input.clone(), + terminal: self.terminal.clone(), + client: self.client.clone(), + wakeups: self.wakeups.clone(), + history_loading: self.history_loading.clone(), + pending_scroll: self.pending_scroll.clone(), + } } } @@ -154,12 +182,61 @@ impl Session { pub struct SessionAccess { pub info: control::Session, input: Arc>, + terminal: Arc>, + client: Client, + wakeups: mpsc::UnboundedSender, + history_loading: Arc, + pending_scroll: Arc>, } impl SessionAccess { pub fn send(&self, bytes: &[u8]) -> zeddy_herdr::Result<()> { self.input.lock().expect("session input mutex").send(bytes) } + + pub fn scroll(&self, lines: i32) -> bool { + match self.terminal.lock().expect("terminal mutex").scroll(lines) { + ScrollResult::Changed => true, + ScrollResult::Unchanged => false, + ScrollResult::NeedsHistory => { + let mut pending = self.pending_scroll.lock().expect("pending scroll mutex"); + *pending = pending.saturating_add(lines); + drop(pending); + self.fetch_history(); + false + } + } + } + + fn fetch_history(&self) { + if self.history_loading.swap(true, Ordering::AcqRel) { + return; + } + let client = self.client.clone(); + let pane = self.info.id.clone(); + let terminal = self.terminal.clone(); + let loading = self.history_loading.clone(); + let loading_on_failure = self.history_loading.clone(); + let pending = self.pending_scroll.clone(); + let wakeups = self.wakeups.clone(); + let spawned = + std::thread::Builder::new().name(format!("zeddy-history-{pane}")).spawn(move || { + let requested_at = terminal.lock().expect("terminal mutex").generation(); + let history = client.history(&pane, HISTORY_LINES); + if let Ok(history) = history { + let mut terminal = terminal.lock().expect("terminal mutex"); + let lines = std::mem::take(&mut *pending.lock().expect("pending scroll mutex")); + terminal.load_history(&history, lines, requested_at); + } else { + *pending.lock().expect("pending scroll mutex") = 0; + } + loading.store(false, Ordering::Release); + let _ = wakeups.unbounded_send(()); + }); + if spawned.is_err() { + loading_on_failure.store(false, Ordering::Release); + } + } } impl std::fmt::Debug for Session { diff --git a/crates/zeddy/src/terminal.rs b/crates/zeddy/src/terminal.rs index f608d166..94ab5b96 100644 --- a/crates/zeddy/src/terminal.rs +++ b/crates/zeddy/src/terminal.rs @@ -34,15 +34,46 @@ use zeddy_vt::{Screen, Size}; /// ignores a size it is already running at, so a steady window costs one /// comparison per frame and a dragged one costs a resize per frame. #[derive(Debug, Clone, Default)] -pub struct Fit(Rc>>); +pub struct Fit { + size: Rc>>, + line_height: Rc>>, + scroll_px: Rc>, +} impl Fit { pub fn get(&self) -> Option { - self.0.get() + self.size.get() } fn set(&self, size: Size) -> bool { - self.0.replace(Some(size)) != Some(size) + self.size.replace(Some(size)) != Some(size) + } + + fn measure(&self, size: Size, line_height: Pixels) -> bool { + self.line_height.set(Some(line_height)); + self.set(size) + } + + /// Quantize a wheel or trackpad gesture into terminal lines. + /// + /// Pixel deltas accumulate until they cross a full row, while traditional + /// mouse-wheel line deltas pass through exactly. + pub fn wheel_lines(&self, event: &gpui::ScrollWheelEvent) -> Option { + let line_height = self.line_height.get()?; + match event.touch_phase { + gpui::TouchPhase::Started => { + self.scroll_px.set(0.); + } + gpui::TouchPhase::Ended | gpui::TouchPhase::Cancelled => return None, + gpui::TouchPhase::Moved => {} + } + + let line_height = line_height / px(1.); + let accumulated = + self.scroll_px.get() + event.delta.pixel_delta(px(line_height)).y / px(1.); + let lines = (accumulated / line_height).trunc() as i32; + self.scroll_px.set(accumulated - lines as f32 * line_height); + (lines != 0).then_some(lines) } } @@ -154,10 +185,11 @@ impl Element for TerminalElement { .max(px(1.)); let cell = size(em, self.appearance.line_height); - let fit_changed = self.fit.set(Size::new( + let measured = Size::new( (bounds.size.width / cell.width).floor() as u16, (bounds.size.height / cell.height).floor() as u16, - )); + ); + let fit_changed = self.fit.measure(measured, cell.height); if fit_changed { // `Window::refresh` is intentionally ignored while GPUI is in a // draw pass. Defer it until the pass completes so the next render @@ -295,4 +327,42 @@ mod tests { assert!(fit.set(Size::new(120, 40))); assert_eq!(fit.get(), Some(Size::new(120, 40))); } + + #[test] + fn wheel_deltas_are_measured_in_terminal_lines() { + let fit = Fit::default(); + fit.measure(Size::new(80, 24), px(20.)); + let event = gpui::ScrollWheelEvent { + delta: gpui::ScrollDelta::Lines(point(0., 2.)), + ..Default::default() + }; + + assert_eq!(fit.wheel_lines(&event), Some(2)); + } + + #[test] + fn trackpad_pixels_accumulate_to_complete_rows() { + let fit = Fit::default(); + fit.measure(Size::new(80, 24), px(20.)); + let event = |pixels| gpui::ScrollWheelEvent { + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(pixels))), + ..Default::default() + }; + + assert_eq!(fit.wheel_lines(&event(9.)), None); + assert_eq!(fit.wheel_lines(&event(11.)), Some(1)); + } + + #[test] + fn a_trackpad_gestures_first_delta_is_not_dropped() { + let fit = Fit::default(); + fit.measure(Size::new(80, 24), px(20.)); + let event = gpui::ScrollWheelEvent { + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(20.))), + touch_phase: gpui::TouchPhase::Started, + ..Default::default() + }; + + assert_eq!(fit.wheel_lines(&event), Some(1)); + } } diff --git a/crates/zeddy/tests/live_session.rs b/crates/zeddy/tests/live_session.rs index b442cf0c..81458019 100644 --- a/crates/zeddy/tests/live_session.rs +++ b/crates/zeddy/tests/live_session.rs @@ -120,6 +120,49 @@ fn a_shell_paints_something_within_a_few_seconds() { assert!(text.contains("zeddy-live-marker"), "the echo never reached the screen"); } +#[test] +#[ignore = "needs a real herdr daemon"] +fn scrollback_survives_the_real_frame_stream() { + let live = Live::start(); + let client = &live.client; + let workspace = + client.open_workspace(&std::env::temp_dir(), Some("zeddy-modes")).expect("a workspace"); + let session = client.start_session(&workspace, None).expect("a session"); + let size = Size::new(80, 24); + let attachment = + client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); + let (mut frames, mut input) = attachment.split(); + let mut terminal = Terminal::new(size); + + input + .send(b"i=1; while [ $i -le 40 ]; do printf 'scroll-%02d\\r\\n' $i; i=$((i+1)); done\r") + .expect("send scrolling output"); + + for _ in 0..20 { + let frame = frames.next_frame().expect("the stream stays valid").expect("a frame"); + if frame.full { + terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); + } + terminal.feed(&frame.bytes); + if terminal.screen().to_text().contains("scroll-40") { + break; + } + } + + let live_screen = terminal.screen().to_text(); + assert!(live_screen.contains("scroll-40"), "the command did not finish painting"); + let requested_at = terminal.generation(); + let ansi = client.history(&session.id, 10_000).expect("read host scrollback"); + assert!( + terminal.load_history(&ansi, i32::MAX, requested_at), + "host scrollback did not move the viewport" + ); + let history = terminal.screen().to_text(); + let _ = client.close_session(&session.id); + assert!(history.contains("scroll-01"), "the oldest received output was not retained"); + assert!(!history.contains("scroll-40"), "scrolling did not move away from the live viewport"); +} + #[test] #[ignore = "needs a real herdr daemon"] fn a_broken_transport_recovers_without_resurrecting_dead_sessions() { diff --git a/docs/acceptance.md b/docs/acceptance.md index 8726aeed..93461a12 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -15,8 +15,9 @@ shipping architecture: cargo test -p zeddy --test live_session -- --ignored --nocapture --test-threads=1 ``` -That suite must paint a real shell, hard-kill Herdr, observe the broken stream, -replace the daemon, and reject the stale session identity. +That suite must paint a real shell, load ANSI host scrollback, hard-kill Herdr, +observe the broken stream, replace the daemon, and reject the stale session +identity. ## Visual matrix @@ -58,6 +59,12 @@ directional focus, move-to-existing-pane, join, Settings singleton focus, native `Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. Close the last workspace and confirm Settings closes too. +Print more than two viewports of styled output in a terminal. With both a mouse +wheel and a trackpad, move to the oldest row and back to the live prompt. Confirm +small pixel deltas accumulate smoothly, colors survive in history, new output +does not pull a historical viewport to the bottom, and scrolling up again after +returning to the prompt refreshes the host history. + Open the chevron menu in both chrome modes. Sidebar mode offers `Switch to Tabbed mode`, a trailing-checkmarked `Show only active space` toggle, a separator, and `Settings`; tabbed mode offers `Switch to Sidebar mode`, a separator, and diff --git a/docs/adr/0004-the-vt-core.md b/docs/adr/0004-the-vt-core.md index b12dc42e..45536eaf 100644 --- a/docs/adr/0004-the-vt-core.md +++ b/docs/adr/0004-the-vt-core.md @@ -3,7 +3,8 @@ ## Decision `zeddy-vt` wraps `alacritty_terminal` — Zed's fork, at the revision Zed's own -terminal uses. Bytes in, a `Screen` out. That is the whole public surface. +terminal uses. Repaint bytes and optional ANSI host history go in; a `Screen` +comes out. ## Why @@ -25,12 +26,16 @@ render pass to the lifetime of an emulator owned by a different thread than the one painting. At the sizes a terminal runs — a few thousand cells — the copy is not what makes a frame slow. -## No scrollback +## Host-backed scrollback -`scrolling_history` is zero. herdr's frame stream sends the viewport and has no -way to move it back through history, so a scrollback buffer here would be one -nothing can ever scroll to. History, when zeddy grows it, comes from the control -plane and is a different rendering. +The live emulator keeps `scrolling_history` at zero because herdr's frame stream +sends only viewport repaints; treating those repaints as raw PTY output creates +duplicate and missing history. On the first upward wheel gesture, zeddy reads +ANSI-styled `recent` history through Herdr's `pane.read` control method on a +background thread. `zeddy-vt` parses that into a separate historical emulator +and moves its display offset. Returning to offset zero renders the live emulator +again. New output marks a bottomed history snapshot stale, and a resize discards +it, so the next upward gesture asks Herdr for an authoritative replacement. ## Colour is not resolved here From 3283191607dad8fb8a5e6fb2f3a896670465481c Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 12:30:13 +0800 Subject: [PATCH 025/110] Move workspace controls into macOS title bar --- .plan/maps/chartr-zeddy-workspace/spec.md | 14 +- crates/zeddy/src/app.rs | 159 ++++++++++++++++++---- crates/zeddy/src/chrome/sidebar.rs | 47 +------ crates/zeddy/src/chrome/tabs.rs | 45 ++---- crates/zeddy/src/title_bar.rs | 2 +- docs/acceptance.md | 15 +- 6 files changed, 174 insertions(+), 108 deletions(-) diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index 908d190a..50f29267 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -39,11 +39,15 @@ Offer tabbed and sidebar projections over the same model. Both show every standalone item and every pane group as one outer entry. Standalone terminals use Herdr's live agent or foreground-process inference before falling back to the persistent Herdr tab label; pane groups use the neutral `Grouped Tabs` -title. Tabbed mode places that collection beside the active space name; -sidebar mode places it beneath each visible space. Selecting a group renders its -local draggable pane tab bars, while selecting a standalone item renders no -redundant inner bar. Only the active pane exposes compact split/zoom controls. -Presentation never changes item ownership. +title. Tabbed mode places that collection in a horizontal strip beneath the +title bar; sidebar mode places it beneath each visible space. Selecting a group +renders its local draggable pane tab bars, while selecting a standalone item +renders no redundant inner bar. Only the active pane exposes compact split/zoom +controls. +On macOS, the active space name is selected from the title bar beside the +traffic lights, and the presentation menu stays in the title bar's far-right +corner; platforms with a native system title bar retain those controls in the +in-app chrome. Presentation never changes item ownership. In the all-spaces sidebar, space headings directly sort their complete cards. Sorting uses measured variable-height midpoints, remains active during horizontal diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index c81a6fc2..22bb8f5c 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -1663,6 +1663,108 @@ impl Zeddy { .into_any_element() } + fn view_menu(&self, on: chrome::Emit) -> AnyElement { + match self.mode { + Mode::Sidebar => { + let active_space_only = self.sidebar_scope == SidebarScope::ActiveSpace; + PopoverMenu::new("chrome-menu") + .trigger_with_tooltip( + IconButton::new("chrome-menu-trigger", IconName::ChevronDown) + .icon_size(IconSize::Small), + Tooltip::text("View options"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let switch = on.clone(); + let toggle_scope = on.clone(); + let settings = on.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Switch to Tabbed mode", None, move |window, cx| { + switch(Action::SwitchToTabs, window, cx) + }) + .toggleable_entry( + "Show only active space", + active_space_only, + IconPosition::End, + None, + move |window, cx| { + toggle_scope(Action::ToggleActiveSpaceOnly, window, cx) + }, + ) + .separator() + .entry( + "Settings", + None, + move |window, cx| settings(Action::OpenSettings, window, cx), + ) + })) + }) + .into_any_element() + } + Mode::Tabs => PopoverMenu::new("chrome-menu") + .trigger_with_tooltip( + IconButton::new("chrome-menu-trigger", IconName::ChevronDown) + .icon_size(IconSize::Small), + Tooltip::text("View options"), + ) + .anchor(Anchor::TopRight) + .menu(move |window, cx| { + let switch = on.clone(); + let settings = on.clone(); + Some(ContextMenu::build(window, cx, move |menu, _, _| { + menu.entry("Switch to Sidebar mode", None, move |window, cx| { + switch(Action::SwitchToSidebar, window, cx) + }) + .separator() + .entry("Settings", None, move |window, cx| { + settings(Action::OpenSettings, window, cx) + }) + })) + }) + .into_any_element(), + } + } + + fn workspace_title_bar(&self, controls: Option<(AnyElement, AnyElement)>) -> AnyElement { + if !cfg!(target_os = "macos") { + return self.title_bar.clone().into_any_element(); + } + + let mut overlays = Vec::with_capacity(2); + if let Some((space_switcher, view_menu)) = controls { + overlays.push( + h_flex() + .absolute() + // Clear the native macOS traffic-light cluster. + .left(px(78.)) + .top_0() + .h(px(crate::title_bar::HEIGHT)) + .max_w(px(200.)) + .child(space_switcher) + .into_any_element(), + ); + overlays.push( + h_flex() + .absolute() + .right(px(6.)) + .top_0() + .h(px(crate::title_bar::HEIGHT)) + .child(view_menu) + .into_any_element(), + ); + } + + div() + .id("workspace-title-bar-with-controls") + .relative() + .w_full() + .h(px(crate::title_bar::HEIGHT)) + .flex_none() + .child(self.title_bar.clone()) + .children(overlays) + .into_any_element() + } + fn new_item_menu(&mut self, cx: &mut Context) -> AnyElement { let weak = cx.weak_entity(); let panes: Vec<_> = self.catalog.panes().into_iter().cloned().collect(); @@ -2964,7 +3066,6 @@ impl Render for Zeddy { let mut sidebar_spaces = self.sidebar_spaces(cx); self.space_sorter.arrange(&mut sidebar_spaces, |space| space.id); let chrome_entries: &[Entry] = &entries; - let switcher = self.space_switcher(window, cx); let new_item = self.new_item_menu(cx); let (background, text, workspace_background) = { let colors = cx.theme().colors(); @@ -2974,6 +3075,9 @@ impl Render for Zeddy { let on_action = cx.listener(|this, action: &Action, window, cx| this.act(action.clone(), window, cx)); let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); + let title_controls = cfg!(target_os = "macos") + .then(|| (self.space_switcher(window, cx), self.view_menu(emit.clone()))); + let title_bar = self.workspace_title_bar(title_controls); let workspace = v_flex() .flex_1() @@ -2983,28 +3087,35 @@ impl Render for Zeddy { .child(self.workspace_pane(window, cx)); let body = match self.mode { - Mode::Sidebar => h_flex() - .w_full() - .flex_1() - .min_h_0() - .child(chrome::sidebar::render( - &sidebar_spaces, - switcher, - emit.clone(), - self.sidebar_scope == SidebarScope::ActiveSpace, - &self.space_sorter, - self.sidebar_width, - cx, - )) - .child(workspace) - .into_any_element(), - Mode::Tabs => v_flex() - .w_full() - .flex_1() - .min_h_0() - .child(chrome::tabs::render(chrome_entries, switcher, new_item, emit, cx)) - .child(workspace) - .into_any_element(), + Mode::Sidebar => { + let controls = (!cfg!(target_os = "macos")) + .then(|| (self.space_switcher(window, cx), self.view_menu(emit.clone()))); + h_flex() + .w_full() + .flex_1() + .min_h_0() + .child(chrome::sidebar::render( + &sidebar_spaces, + controls, + emit.clone(), + &self.space_sorter, + self.sidebar_width, + cx, + )) + .child(workspace) + .into_any_element() + } + Mode::Tabs => { + let controls = (!cfg!(target_os = "macos")) + .then(|| (self.space_switcher(window, cx), self.view_menu(emit.clone()))); + v_flex() + .w_full() + .flex_1() + .min_h_0() + .child(chrome::tabs::render(chrome_entries, controls, new_item, emit, cx)) + .child(workspace) + .into_any_element() + } }; let command_palette = self.command_palette(cx); @@ -3110,7 +3221,7 @@ impl Render for Zeddy { this.toggle_command_palette(window, cx) })) .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) - .child(self.title_bar.clone()) + .child(title_bar) .child(body) .children(command_palette) .children(rename_space) diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 97a47c70..5e7996ad 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -426,9 +426,8 @@ impl SpaceSorter { pub fn render( spaces: &[SpaceEntries], - space_switcher: AnyElement, + controls: Option<(AnyElement, AnyElement)>, on: Emit, - active_space_only: bool, sorter: &SpaceSorter, width: f32, cx: &App, @@ -440,6 +439,8 @@ pub fn render( selected: sidebar_colors.session_active, }; let mut cards = Vec::with_capacity(spaces.len()); + let header = controls + .map(|(space_switcher, view_menu)| header(space_switcher, view_menu).into_any_element()); let mut index = 0; let now = cx.background_executor().now(); let reduce_motion = cx.reduce_motion(); @@ -637,7 +638,7 @@ pub fn render( .bg(colors.panel_background) .border_r_1() .border_color(colors.border) - .child(header(space_switcher, on.clone(), active_space_only)) + .children(header) .child( v_flex() .id("sessions") @@ -668,50 +669,14 @@ pub fn render( )) } -fn header(space_switcher: AnyElement, on: Emit, active_space_only: bool) -> impl IntoElement { - let menu_actions = on; +fn header(space_switcher: AnyElement, view_menu: AnyElement) -> impl IntoElement { h_flex() .h(px(36.)) .px_2() .gap_1() .justify_between() .child(h_flex().min_w_0().flex_1().child(space_switcher)) - .child( - h_flex().gap_px().child( - PopoverMenu::new("chrome-menu") - .trigger_with_tooltip( - IconButton::new("chrome-menu-trigger", IconName::ChevronDown) - .icon_size(IconSize::Small), - Tooltip::text("View options"), - ) - .anchor(Anchor::TopRight) - .menu(move |window, cx| { - let switch = menu_actions.clone(); - let toggle_scope = menu_actions.clone(); - let settings = menu_actions.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - menu.entry("Switch to Tabbed mode", None, move |window, cx| { - switch(Action::SwitchToTabs, window, cx) - }) - .toggleable_entry( - "Show only active space", - active_space_only, - IconPosition::End, - None, - move |window, cx| { - toggle_scope(Action::ToggleActiveSpaceOnly, window, cx) - }, - ) - .separator() - .entry( - "Settings", - None, - move |window, cx| settings(Action::OpenSettings, window, cx), - ) - })) - }), - ), - ) + .child(h_flex().gap_px().child(view_menu)) } fn row( diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index 47a647a3..cea68c5d 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -1,14 +1,13 @@ -//! Tabs mode: standalone tabs and pane groups beside the active space name. +//! Tabs mode: standalone tabs and pane groups in a horizontal strip. //! //! The mode for a handful of sessions you are switching between quickly. A tab //! has no second line, so the agent's name is dropped here rather than //! squeezed in — the dot still carries the state, and the title carries the //! identity. -use gpui::{Anchor, Role}; +use gpui::Role; use ui::{ - ButtonSize, IconButtonShape, PopoverMenu, Tab, TabBar, TabPosition, Tooltip, prelude::*, - right_click_menu, + ButtonSize, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*, right_click_menu, }; use super::Emit; @@ -19,12 +18,11 @@ const SPACE_SWITCHER_MAX_WIDTH: f32 = 200.; pub fn render( entries: &[Entry], - space_switcher: AnyElement, + controls: Option<(AnyElement, AnyElement)>, new_item: AnyElement, on: Emit, cx: &App, ) -> impl IntoElement { - let menu_actions = on.clone(); let active_index = entries.iter().position(|entry| entry.selected); let tabs_with_pinned_new_item = h_flex() .w_full() @@ -52,31 +50,16 @@ pub fn render( .child(new_item), ); - TabBar::new("workspace-tabs") - .start_child(h_flex().flex_none().max_w(px(SPACE_SWITCHER_MAX_WIDTH)).child(space_switcher)) - .child(tabs_with_pinned_new_item) - .end_child( - PopoverMenu::new("chrome-menu") - .trigger_with_tooltip( - IconButton::new("chrome-menu-trigger", IconName::ChevronDown) - .icon_size(IconSize::Small), - Tooltip::text("View options"), - ) - .anchor(Anchor::TopRight) - .menu(move |window, cx| { - let switch = menu_actions.clone(); - let settings = menu_actions.clone(); - Some(ContextMenu::build(window, cx, move |menu, _, _| { - menu.entry("Switch to Sidebar mode", None, move |window, cx| { - switch(Action::SwitchToSidebar, window, cx) - }) - .separator() - .entry("Settings", None, move |window, cx| { - settings(Action::OpenSettings, window, cx) - }) - })) - }), - ) + let tab_bar = TabBar::new("workspace-tabs").child(tabs_with_pinned_new_item); + match controls { + Some((space_switcher, view_menu)) => tab_bar + .start_child( + h_flex().flex_none().max_w(px(SPACE_SWITCHER_MAX_WIDTH)).child(space_switcher), + ) + .end_child(view_menu) + .into_any_element(), + None => tab_bar.into_any_element(), + } } fn tab( diff --git a/crates/zeddy/src/title_bar.rs b/crates/zeddy/src/title_bar.rs index 5ffb3ffc..5c76f5f0 100644 --- a/crates/zeddy/src/title_bar.rs +++ b/crates/zeddy/src/title_bar.rs @@ -3,7 +3,7 @@ use gpui::{ElementId, MouseButton, TitlebarOptions, point, px}; use ui::prelude::*; -const HEIGHT: f32 = 34.; +pub const HEIGHT: f32 = 34.; /// Use the native title bar everywhere except macOS, where Chartr draws the /// background and AppKit keeps responsibility for the traffic-light controls. diff --git a/docs/acceptance.md b/docs/acceptance.md index 93461a12..51b4476d 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -65,15 +65,18 @@ small pixel deltas accumulate smoothly, colors survive in history, new output does not pull a historical viewport to the bottom, and scrolling up again after returning to the prompt refreshes the host history. -Open the chevron menu in both chrome modes. Sidebar mode offers `Switch to -Tabbed mode`, a trailing-checkmarked `Show only active space` toggle, a separator, -and `Settings`; tabbed mode offers `Switch to Sidebar mode`, a separator, and +Confirm the active-space picker sits in the macOS title bar immediately after +the traffic lights, and the chevron menu sits at the far-right corner in both +chrome modes. Sidebar mode offers `Switch to Tabbed mode`, a +trailing-checkmarked `Show only active space` toggle, a separator, and +`Settings`; tabbed mode offers `Switch to Sidebar mode`, a separator, and `Settings`. Switching presentation or sidebar scope updates immediately and -survives relaunch. +survives relaunch. On platforms with a native system title bar, confirm both +controls retain their in-app chrome positions. In tabbed mode, the `+` control follows the last outer tab while they fit. When -the tabs overflow, only the tabs scroll: `+` pins beside their right edge, and -the chevron view-menu control remains pinned at the far right. The padded `+` +the tabs overflow, only the tabs scroll: `+` pins beside their right edge, while +the title-bar chevron remains pinned at the window's far right. The padded `+` cell retains a left divider against the scrolling tabs. Every tab retains its minimum clickable width, including pane-local tabs within grouped workspaces. From a368dba0354b436d000af6fa22e6a37f7b048a62 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 12:59:09 +0800 Subject: [PATCH 026/110] Unify workspace tab chrome --- crates/zeddy/src/app.rs | 213 ++++++++++++++--------------- crates/zeddy/src/chrome.rs | 137 ++++++++++++++++++- crates/zeddy/src/chrome/sidebar.rs | 2 +- crates/zeddy/src/chrome/tabs.rs | 121 +++++++--------- docs/acceptance.md | 8 ++ 5 files changed, 294 insertions(+), 187 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 22bb8f5c..3926da73 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -19,7 +19,7 @@ use gpui::{ }; use ui::{ Banner, ButtonLike, ButtonSize, IconButtonShape, IconPosition, ListItem, ListItemSpacing, - PopoverMenu, Severity, Tab, TabBar, TabPosition, Tooltip, prelude::*, + PopoverMenu, Severity, TabBar, Tooltip, prelude::*, }; use zeddy_herdr::{Namespace, Sidecar, WorkspaceId, control::Client}; use zeddy_plugin::{InstanceContext, manifest::Multiplicity}; @@ -1769,10 +1769,7 @@ impl Zeddy { let weak = cx.weak_entity(); let panes: Vec<_> = self.catalog.panes().into_iter().cloned().collect(); PopoverMenu::new("new-item-menu") - .trigger_with_tooltip( - IconButton::new("new-item", IconName::Plus).icon_size(IconSize::Small), - Tooltip::text("New…"), - ) + .trigger_with_tooltip(chrome::new_item_button("new-item"), Tooltip::text("New…")) .anchor(Anchor::TopRight) .menu(move |window, cx| { let weak = weak.clone(); @@ -1807,8 +1804,7 @@ impl Zeddy { ) -> AnyElement { let button_id = format!("new-item-pane-{}-{}", tab_id.get(), pane_id.get()); let start = weak.clone(); - IconButton::new(button_id, IconName::Plus) - .icon_size(IconSize::XSmall) + chrome::new_item_button(button_id) .tooltip(Tooltip::text("New session in this pane")) .on_click(move |_, _, cx| { cx.stop_propagation(); @@ -1823,6 +1819,16 @@ impl Zeddy { .into_any_element() } + fn pane_new_item_cell( + &self, + tab_id: WorkspaceTabId, + pane_id: LayoutPaneId, + weak: &gpui::WeakEntity, + cx: &App, + ) -> AnyElement { + chrome::new_item_cell(self.pane_new_item_button(tab_id, pane_id, weak), cx) + } + fn web_plugin_focus_handler( space: Entity, cx: &Context, @@ -2437,7 +2443,7 @@ impl Zeddy { if pane.active().is_some() { self.pane_header(space, tab_id, layout, pane_id, on, weak, cx) } else { - self.empty_pane_header(tab_id, pane_id, weak) + self.empty_pane_header(tab_id, pane_id, weak, cx) } }); let content = pane @@ -2603,32 +2609,31 @@ impl Zeddy { tab_id: WorkspaceTabId, pane_id: LayoutPaneId, weak: &gpui::WeakEntity, + cx: &App, ) -> AnyElement { let close = weak.clone(); TabBar::new(format!("workspace-tab-{}-pane-{}-empty", tab_id.get(), pane_id.get())) + .child(self.pane_new_item_cell(tab_id, pane_id, weak, cx)) + .child(div().h_full().flex_grow_1()) .end_child( - h_flex().gap_1().child(self.pane_new_item_button(tab_id, pane_id, weak)).child( - IconButton::new( - format!("close-empty-pane-{}-{}", tab_id.get(), pane_id.get()), - IconName::Close, - ) - .shape(IconButtonShape::Square) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall) - .aria_label("Close Empty Pane") - .tooltip(Tooltip::text("Close Empty Pane")) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - let _ = close.update(cx, |this, cx| { - if let Some(space) = this.active.clone() { - space.update(cx, |space, _| { - space.remove_empty_pane(tab_id, pane_id) - }); - } - cx.notify(); - }); - }), - ), + IconButton::new( + format!("close-empty-pane-{}-{}", tab_id.get(), pane_id.get()), + IconName::Close, + ) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .aria_label("Close Empty Pane") + .tooltip(Tooltip::text("Close Empty Pane")) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + let _ = close.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, _| space.remove_empty_pane(tab_id, pane_id)); + } + cx.notify(); + }); + }), ) .into_any_element() } @@ -2656,13 +2661,7 @@ impl Zeddy { let status = item.status(); let process_running = item.process_running(); let ended = item.ended(); - let position = if index == 0 { - TabPosition::First - } else if index + 1 == pane.items().len() { - TabPosition::Last - } else { - TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) - }; + let position = chrome::tab_position(index, pane.items().len(), active_index); let select = *id; let close = *id; let select_item = on.clone(); @@ -2678,80 +2677,71 @@ impl Zeddy { top_level: false, grouped: false, }; + let close_slot = IconButton::new( + format!("close-pane-{}-item-{}", pane_id.get(), id.get()), + IconName::Close, + ) + .shape(IconButtonShape::Square) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Close")) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + close_item(Action::Close { space: None, item: close }, window, cx) + }) + .into_any_element(); Some( - Tab::new(format!("pane-{}-item-{}", pane_id.get(), id.get())) - .role(Role::Tab) - .aria_label(item.title()) - .aria_selected(selected) - .position(position) - .toggle_state(selected) - .on_click(move |_, window, cx| { - select_item(Action::Select { space: None, item: select }, window, cx) - }) - .on_drag(dragged, |dragged, offset, _, cx| { - dragged_item_preview(dragged, offset, cx) - }) - .can_drop(move |value, _, _| { - value - .downcast_ref::() - .is_some_and(|dragged| !dragged.grouped && dragged.space == drop_space) - }) - .drag_over::(move |tab, dragged, _, cx| { - let mut tab = tab - .bg(cx.theme().colors().drop_target_background) - .border_color(cx.theme().colors().drop_target_border) - .border_0(); - if index < dragged.index { - tab = tab.border_l_2(); - } else if index > dragged.index { - tab = tab.border_r_2(); - } - tab - }) - .on_drop(move |dragged: &DraggedItem, window, cx| { - let dragged = dragged.clone(); - let _ = drop_item.update(cx, |this, cx| { - this.handle_item_drop( - &dragged, tab_id, pane_id, index, false, window, cx, - ); - }); - }) - .start_slot(chrome::status_indicator( - status, - process_running, - ended, - false, - &space_key, - *id, - cx, - )) - .end_slot( - IconButton::new( - format!("close-pane-{}-item-{}", pane_id.get(), id.get()), - IconName::Close, - ) - .shape(IconButtonShape::Square) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Close")) - .on_click(move |_, window, cx| { - cx.stop_propagation(); - close_item(Action::Close { space: None, item: close }, window, cx) - }), - ) - .child(chrome::tab_label(item.title())) - .into_any_element(), + chrome::ItemTab::new( + format!("pane-{}-item-{}", pane_id.get(), id.get()), + item.title(), + selected, + position, + &space_key, + *id, + ) + .activity(status, process_running, ended) + .close_slot(Some(close_slot)) + .build(cx) + .on_click(move |_, window, cx| { + select_item(Action::Select { space: None, item: select }, window, cx) + }) + .on_drag(dragged, |dragged, offset, _, cx| { + dragged_item_preview(dragged, offset, cx) + }) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| !dragged.grouped && dragged.space == drop_space) + }) + .drag_over::(move |tab, dragged, _, cx| { + let mut tab = tab + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + if index < dragged.index { + tab = tab.border_l_2(); + } else if index > dragged.index { + tab = tab.border_r_2(); + } + tab + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + let dragged = dragged.clone(); + let _ = drop_item.update(cx, |this, cx| { + this.handle_item_drop(&dragged, tab_id, pane_id, index, false, window, cx); + }); + }) + .into_any_element(), ) }); let append_drop = weak.clone(); let append_index = pane.items().len(); let append_space = space_key.clone(); - let tab_bar_drop_target = div() + let tabs_with_pinned_new_item = h_flex() .id(format!("pane-{}-tab-bar-drop-target", pane_id.get())) - .min_w_6() - .h(Tab::container_height(cx)) - .flex_grow_1() - .child("") + .w_full() + .min_w_0() + .h_full() .can_drop(move |value, _, _| { value .downcast_ref::() @@ -2773,11 +2763,18 @@ impl Zeddy { cx, ); }); - }); + }) + .child( + h_flex() + .id(format!("pane-{}-tab-list", pane_id.get())) + .min_w_0() + .flex_shrink_1() + .overflow_x_scroll() + .children(tabs), + ) + .child(self.pane_new_item_cell(tab_id, pane_id, weak, cx)); TabBar::new(format!("workspace-tab-{}-pane-{}-tabs", tab_id.get(), pane_id.get())) - .children(tabs) - .child(tab_bar_drop_target) - .end_child(self.pane_new_item_button(tab_id, pane_id, weak)) + .child(tabs_with_pinned_new_item) .into_any_element() } diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index 0f5e4f78..dec95ed2 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -14,14 +14,143 @@ use crate::{ fonts::UI_LABEL_DEFAULT, workspace::{ItemId, PaneId, WorkspaceTabId}, }; -use gpui::{EntityId, Pixels, SharedString}; -use ui::{CommonAnimationExt, prelude::*}; +use gpui::{ElementId, EntityId, Pixels, Role, SharedString}; +use ui::{CommonAnimationExt, IconButton, Tab, TabPosition, prelude::*}; use zeddy_herdr::control::SessionStatus; const TAB_LABEL_MIN_WIDTH: f32 = 24.; -pub(crate) fn tab_label(title: impl Into) -> impl IntoElement { - div().min_w(px(TAB_LABEL_MIN_WIDTH)).child(Label::new(title).size(UI_LABEL_DEFAULT).truncate()) +pub(crate) fn new_item_button(id: impl Into) -> IconButton { + IconButton::new(id, IconName::Plus).icon_size(IconSize::Small) +} + +pub(crate) fn new_item_cell(button: impl IntoElement, cx: &App) -> AnyElement { + h_flex() + // Zed's inner TabBar row derives its height from its tabs. Keep an + // empty strip at the same height instead of collapsing to the button. + .h(Tab::container_height(cx)) + .flex_none() + // Collapse this divider onto the last tab's border. + .ml(px(-1.)) + .px(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .child(button) + .into_any_element() +} + +fn tab_label(title: impl Into, selected: bool) -> impl IntoElement { + h_flex().when(selected, |label| label.pr_px()).child( + div() + .min_w(px(TAB_LABEL_MIN_WIDTH)) + .child(Label::new(title).size(UI_LABEL_DEFAULT).truncate()), + ) +} + +/// Resolve the Zed border shape shared by outer and pane-local tab strips. +pub(crate) fn tab_position(index: usize, count: usize, active_index: Option) -> TabPosition { + if index == 0 { + TabPosition::First + } else if index + 1 == count { + TabPosition::Last + } else { + TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) + } +} + +/// The common visual core for every workspace tab. +/// +/// Zed's selected [`Tab`] replaces one horizontal pixel of padding with a +/// border. GPUI paints that border inside the box, so its intrinsic width is +/// one pixel smaller than the inactive state. Restore that pixel here to keep +/// selection from shifting the rest of either tab strip. +pub(crate) struct ItemTab<'a> { + id: ElementId, + title: SharedString, + aria_label: SharedString, + selected: bool, + position: TabPosition, + status: Option, + process_running: bool, + ended: bool, + grouped: bool, + space: &'a str, + key: ItemId, + close_slot: Option, +} + +impl<'a> ItemTab<'a> { + pub(crate) fn new( + id: impl Into, + title: impl Into, + selected: bool, + position: TabPosition, + space: &'a str, + key: ItemId, + ) -> Self { + let title = title.into(); + Self { + id: id.into(), + aria_label: title.clone(), + title, + selected, + position, + status: None, + process_running: false, + ended: false, + grouped: false, + space, + key, + close_slot: None, + } + } + + pub(crate) fn aria_label(mut self, label: impl Into) -> Self { + self.aria_label = label.into(); + self + } + + pub(crate) fn activity( + mut self, + status: Option, + process_running: bool, + ended: bool, + ) -> Self { + self.status = status; + self.process_running = process_running; + self.ended = ended; + self + } + + pub(crate) fn grouped(mut self, grouped: bool) -> Self { + self.grouped = grouped; + self + } + + pub(crate) fn close_slot(mut self, close_slot: Option) -> Self { + self.close_slot = close_slot; + self + } + + pub(crate) fn build(self, cx: &App) -> Tab { + Tab::new(self.id) + .role(Role::Tab) + .aria_label(self.aria_label) + .aria_selected(self.selected) + .position(self.position) + .toggle_state(self.selected) + .start_slot(status_indicator( + self.status, + self.process_running, + self.ended, + self.grouped, + self.space, + self.key, + cx, + )) + .end_slot::(self.close_slot) + .child(tab_label(self.title, self.selected)) + } } /// One row in the sidebar, or one tab in the strip. diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 5e7996ad..cf5bd010 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -26,7 +26,7 @@ use crate::fonts::{UI_LABEL_DEFAULT, UI_LABEL_SMALL}; use crate::settings::sidebar_theme_colors; /// Limits for the resizable sidebar. -pub const MIN_WIDTH: f32 = 180.; +pub const MIN_WIDTH: f32 = 108.; pub const MAX_WIDTH: f32 = 480.; /// One shared value for layout and FLIP arithmetic. `gap_2` is half a rem; diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index cea68c5d..a397748e 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -5,14 +5,13 @@ //! squeezed in — the dot still carries the state, and the title carries the //! identity. -use gpui::Role; -use ui::{ - ButtonSize, IconButtonShape, Tab, TabBar, TabPosition, Tooltip, prelude::*, right_click_menu, -}; +use ui::{ButtonSize, IconButtonShape, TabBar, Tooltip, prelude::*, right_click_menu}; use super::Emit; -use super::{Action, DraggedItem, Entry, dragged_item_preview, status_indicator}; +use super::{ + Action, DraggedItem, Entry, ItemTab, dragged_item_preview, new_item_cell, tab_position, +}; use crate::components::ContextMenu; const SPACE_SWITCHER_MAX_WIDTH: f32 = 200.; @@ -38,17 +37,7 @@ pub fn render( tab(index, entries.len(), active_index, entry, on.clone(), cx) })), ) - .child( - h_flex() - .h_full() - .flex_none() - // Collapse this divider onto the last tab's border. - .ml(px(-1.)) - .px(DynamicSpacing::Base04.rems(cx)) - .border_l_1() - .border_color(cx.theme().colors().border) - .child(new_item), - ); + .child(new_item_cell(new_item, cx)); let tab_bar = TabBar::new("workspace-tabs").child(tabs_with_pinned_new_item); match controls { @@ -71,13 +60,7 @@ fn tab( cx: &App, ) -> AnyElement { let close = on.clone(); - let position = if index == 0 { - TabPosition::First - } else if index + 1 == count { - TabPosition::Last - } else { - TabPosition::Middle(index.cmp(&active_index.unwrap_or(index))) - }; + let position = tab_position(index, count, active_index); let select = entry.key; let select_item = on.clone(); let ungroup = on.clone(); @@ -119,57 +102,47 @@ fn tab( }) .into_any_element() }); - let tab = Tab::new(("tab", index)) - .role(Role::Tab) - .aria_label(if entry.grouped { - format!("Pane group: {}", entry.title) - } else { - entry.title.clone() - }) - .aria_selected(entry.selected) - .position(position) - .toggle_state(entry.selected) - .on_click(move |_, window, cx| { - select_item(Action::Select { space: Some(space), item: select }, window, cx) - }) - .when(!entry.grouped, |tab| { - tab.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) - }) - .can_drop(move |value, _, _| { - value - .downcast_ref::() - .is_some_and(|dragged| dragged.space == target_space_key && dragged.top_level) - }) - .drag_over::(move |tab, dragged, _, cx| { - let mut tab = tab - .bg(cx.theme().colors().drop_target_background) - .border_color(cx.theme().colors().drop_target_border) - .border_0(); - if target_index < dragged.index { - tab = tab.border_l_2(); - } else if target_index > dragged.index { - tab = tab.border_r_2(); - } - tab - }) - .on_drop(move |dragged: &DraggedItem, window, cx| { - move_tab( - Action::MoveWorkspaceTab { space, tab: dragged.tab, target_index }, - window, - cx, - ); - }) - .start_slot(status_indicator( - entry.status, - entry.process_running, - entry.ended, - entry.grouped, - &entry.space_key, - entry.key, - cx, - )) - .end_slot::(close_slot) - .child(super::tab_label(entry.title.clone())); + let aria_label = + if entry.grouped { format!("Pane group: {}", entry.title) } else { entry.title.clone() }; + let tab = ItemTab::new( + ("tab", index), + entry.title.clone(), + entry.selected, + position, + &entry.space_key, + entry.key, + ) + .aria_label(aria_label) + .activity(entry.status, entry.process_running, entry.ended) + .grouped(entry.grouped) + .close_slot(close_slot) + .build(cx) + .on_click(move |_, window, cx| { + select_item(Action::Select { space: Some(space), item: select }, window, cx) + }) + .when(!entry.grouped, |tab| { + tab.on_drag(dragged, |dragged, offset, _, cx| dragged_item_preview(dragged, offset, cx)) + }) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|dragged| dragged.space == target_space_key && dragged.top_level) + }) + .drag_over::(move |tab, dragged, _, cx| { + let mut tab = tab + .bg(cx.theme().colors().drop_target_background) + .border_color(cx.theme().colors().drop_target_border) + .border_0(); + if target_index < dragged.index { + tab = tab.border_l_2(); + } else if target_index > dragged.index { + tab = tab.border_r_2(); + } + tab + }) + .on_drop(move |dragged: &DraggedItem, window, cx| { + move_tab(Action::MoveWorkspaceTab { space, tab: dragged.tab, target_index }, window, cx); + }); if grouped { right_click_menu(format!("group-tab-menu-{space:?}-{}", close_tab.get())) diff --git a/docs/acceptance.md b/docs/acceptance.md index 51b4476d..4efbc2a6 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -79,6 +79,14 @@ the tabs overflow, only the tabs scroll: `+` pins beside their right edge, while the title-bar chevron remains pinned at the window's far right. The padded `+` cell retains a left divider against the scrolling tabs. Every tab retains its minimum clickable width, including pane-local tabs within grouped workspaces. +Switch selection across both tab strips and confirm tab edges, following tabs, +and trailing controls remain stationary without a one-pixel shift. +Each pane-local `+` likewise follows its last tab on the left and stays pinned +beside the scrolling pane tabs. Outer and pane-local `+` controls use the same +icon size and padded divider cell; an empty outer strip keeps the `+` vertically +centered at the normal tab-bar height. The muted tab-bar background continues +after its compact cell and remains the append drop target without reserving width; +when pane tabs overflow, `+` reaches the pane's right edge. For tab dragging, exercise each pane-body center and edge target, both corner choices, before and after insertion on existing tabs, trailing-strip append, From b96cca5bcd6a00d3a8dd161856b61f1d41819638 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 13:54:34 +0800 Subject: [PATCH 027/110] Use libghostty for terminal key encoding --- Cargo.lock | 40 +++++ Cargo.toml | 11 +- README.md | 4 +- crates/zeddy-vt/Cargo.toml | 1 + crates/zeddy-vt/src/lib.rs | 333 +++++++++++++++++++++++++++++++++-- crates/zeddy/src/app.rs | 55 +++++- crates/zeddy/src/keys.rs | 327 ++++++++++++++++++++++------------ crates/zeddy/src/session.rs | 7 +- crates/zeddy/src/space.rs | 6 + docs/adr/0004-the-vt-core.md | 37 ++-- docs/adr/README.md | 2 +- 11 files changed, 677 insertions(+), 146 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 578d4645..ca0400b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3333,6 +3333,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "int-enum" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366a1634cccc76b4cfd3e7580de9b605e4d93f1edac48d786c1f867c0def495" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3574,6 +3586,21 @@ dependencies = [ "cc", ] +[[package]] +name = "libghostty-vt" +version = "0.2.1" +source = "git+https://github.com/Uzaaft/libghostty-rs?rev=de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec#de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec" +dependencies = [ + "bitflags 2.13.1", + "int-enum", + "libghostty-vt-sys", +] + +[[package]] +name = "libghostty-vt-sys" +version = "0.2.1" +source = "git+https://github.com/Uzaaft/libghostty-rs?rev=de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec#de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec" + [[package]] name = "libloading" version = "0.8.9" @@ -5045,6 +5072,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", +] + [[package]] name = "profiling" version = "1.0.18" @@ -8719,6 +8758,7 @@ name = "zeddy-vt" version = "0.1.0" dependencies = [ "alacritty_terminal", + "libghostty-vt", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0d247a6d..ad60e425 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,10 +53,15 @@ theme = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd zed_assets = { package = "assets", git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } # --- the terminal -------------------------------------------------------- # -# Zed's fork of alacritty's VT core. Taking the same parser Zed's own terminal -# uses is the whole point of picking it over libghostty-vt: no Zig in the -# build, and grid semantics that already agree with the renderer above them. +# Zed's fork of alacritty's VT core supplies the output parser, whose grid +# semantics already agree with the renderer above it. Ghostty supplies the +# input encoder: terminal keyboard protocols are too stateful and too broad to +# reproduce with a table of escape sequences. Both dependencies are contained +# by `zeddy-vt`; the application sees only zeddy-owned screen and input types. alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } +# This is the same safe-binding/Ghostty/Zig pin as chartr-rs. The binding pins +# Ghostty 22d13172cde98a0a4dda05d3d6a3fcb0dd8ed018 and requires Zig 0.16.0. +libghostty-vt = { git = "https://github.com/Uzaaft/libghostty-rs", rev = "de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec", default-features = false } # --- everything else ----------------------------------------------------- anyhow = "1" diff --git a/README.md b/README.md index 1bf634bd..ec44f295 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ sh vendor/herdr/fetch.sh cargo run -p zeddy ``` +Building requires Zig 0.16.0 for the pinned libghostty terminal input encoder. + The supported desktop targets are macOS and Linux under X11 or XWayland. Windows is deferred because Herdr currently uses Unix-domain sockets. Wry's in-window Linux child webviews require X11, so Chartr selects the same GPUI @@ -134,7 +136,7 @@ are development references and are not installed automatically. ```text crates/zeddy/ window, spaces, panes, settings, persistence, UI crates/zeddy-herdr/ private Herdr protocol and lifecycle -crates/zeddy-vt/ terminal parser boundary +crates/zeddy-vt/ Alacritty output parser and Ghostty input encoder boundary crates/zeddy-plugin/ native and manifest authoring contract crates/zeddy-plugin-host/ discovery, loading, and web filesystem broker plugins/ one complete example per plugin tier diff --git a/crates/zeddy-vt/Cargo.toml b/crates/zeddy-vt/Cargo.toml index be887158..2ea0b23f 100644 --- a/crates/zeddy-vt/Cargo.toml +++ b/crates/zeddy-vt/Cargo.toml @@ -8,3 +8,4 @@ repository.workspace = true [dependencies] alacritty_terminal.workspace = true +libghostty-vt.workspace = true diff --git a/crates/zeddy-vt/src/lib.rs b/crates/zeddy-vt/src/lib.rs index b3ee3f7d..dfd12e65 100644 --- a/crates/zeddy-vt/src/lib.rs +++ b/crates/zeddy-vt/src/lib.rs @@ -1,16 +1,17 @@ //! zeddy's only VT boundary. //! -//! Bytes go in, a [`Screen`] comes out. That is the whole contract, and it is -//! the whole reason this crate exists: the renderer above it never sees an -//! escape sequence, and swapping the parser underneath it is a change to one -//! file rather than to the window. +//! Output bytes go in and a [`Screen`] comes out; normalized [`KeyEvent`]s go +//! in and terminal input bytes come out. This is the whole reason the crate +//! exists: neither the renderer nor the keyboard adapter above it sees an +//! escape sequence or an upstream terminal type. //! -//! # Why alacritty's core +//! # Two deliberately different cores //! -//! It is the parser Zed's own terminal uses, and zeddy is built on Zed's -//! frontend. Taking the same one means the grid semantics the renderer assumes -//! and the grid semantics the parser produces already agree — and, unlike -//! libghostty-vt, it needs no Zig in the build. +//! Alacritty parses output because it is the parser Zed's own terminal uses and +//! its grid semantics already agree with Zeddy's renderer. libghostty encodes +//! input because it implements the legacy, xterm, fixterms, and Kitty keyboard +//! protocols as one mode-aware encoder. Both are private implementation +//! details of this boundary. //! //! # Snapshots, not references //! @@ -28,9 +29,199 @@ use alacritty_terminal::{ event::{Event, EventListener}, grid::{Dimensions, Scroll as AlacrittyScroll}, index::{Column, Line, Point}, - term::{Config, cell::Flags}, + term::{Config, TermMode, cell::Flags}, vte::ansi::{Color as AnsiColor, NamedColor, Processor}, }; +use libghostty_vt::key; + +/// An error produced while turning a normalized key event into terminal bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyEncodingError(String); + +impl std::fmt::Display for KeyEncodingError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for KeyEncodingError {} + +impl From for KeyEncodingError { + fn from(error: libghostty_vt::Error) -> Self { + Self(error.to_string()) + } +} + +/// Declares the normalized keyboard and its Ghostty counterpart together, so +/// adding a key cannot leave either the physical identity or its unshifted +/// character behind. +macro_rules! key_codes { + ($($name:ident => $upstream:ident, $unshifted:expr;)*) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[allow(missing_docs)] + pub enum KeyCode { + $($name,)* + } + + impl KeyCode { + const ALL: &'static [Self] = &[$(Self::$name,)*]; + + /// Find the physical key which types `character` without modifiers. + pub fn typing(character: char) -> Option { + Self::ALL.iter().copied().find(|key| key.unshifted() == Some(character)) + } + + /// The character this key types without modifiers, when it has one. + pub fn unshifted(self) -> Option { + match self { + $(Self::$name => $unshifted,)* + } + } + + fn upstream(self) -> key::Key { + match self { + $(Self::$name => key::Key::$upstream,)* + } + } + } + }; +} + +key_codes! { + A => A, Some('a'); B => B, Some('b'); C => C, Some('c'); D => D, Some('d'); + E => E, Some('e'); F => F, Some('f'); G => G, Some('g'); H => H, Some('h'); + I => I, Some('i'); J => J, Some('j'); K => K, Some('k'); L => L, Some('l'); + M => M, Some('m'); N => N, Some('n'); O => O, Some('o'); P => P, Some('p'); + Q => Q, Some('q'); R => R, Some('r'); S => S, Some('s'); T => T, Some('t'); + U => U, Some('u'); V => V, Some('v'); W => W, Some('w'); X => X, Some('x'); + Y => Y, Some('y'); Z => Z, Some('z'); + + Digit0 => Digit0, Some('0'); Digit1 => Digit1, Some('1'); + Digit2 => Digit2, Some('2'); Digit3 => Digit3, Some('3'); + Digit4 => Digit4, Some('4'); Digit5 => Digit5, Some('5'); + Digit6 => Digit6, Some('6'); Digit7 => Digit7, Some('7'); + Digit8 => Digit8, Some('8'); Digit9 => Digit9, Some('9'); + + Backquote => Backquote, Some('`'); Backslash => Backslash, Some('\\'); + BracketLeft => BracketLeft, Some('['); BracketRight => BracketRight, Some(']'); + Comma => Comma, Some(','); Equal => Equal, Some('='); Minus => Minus, Some('-'); + Period => Period, Some('.'); Quote => Quote, Some('\''); Semicolon => Semicolon, Some(';'); + Slash => Slash, Some('/'); Space => Space, Some(' '); + + Enter => Enter, None; Tab => Tab, None; Escape => Escape, None; + Backspace => Backspace, None; Delete => Delete, None; Insert => Insert, None; + Home => Home, None; End => End, None; PageUp => PageUp, None; PageDown => PageDown, None; + ArrowUp => ArrowUp, None; ArrowDown => ArrowDown, None; + ArrowLeft => ArrowLeft, None; ArrowRight => ArrowRight, None; + + F1 => F1, None; F2 => F2, None; F3 => F3, None; F4 => F4, None; + F5 => F5, None; F6 => F6, None; F7 => F7, None; F8 => F8, None; + F9 => F9, None; F10 => F10, None; F11 => F11, None; F12 => F12, None; + F13 => F13, None; F14 => F14, None; F15 => F15, None; F16 => F16, None; + F17 => F17, None; F18 => F18, None; F19 => F19, None; F20 => F20, None; + F21 => F21, None; F22 => F22, None; F23 => F23, None; F24 => F24, None; + F25 => F25, None; + + BrowserBack => BrowserBack, None; BrowserForward => BrowserForward, None; + Copy => Copy, None; Cut => Cut, None; Paste => Paste, None; + Unidentified => Unidentified, None; +} + +/// One keyboard event after the window system's spelling has been normalized. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeyEvent { + pub code: KeyCode, + /// Text after Shift/layout processing but before Control/Alt transformations. + pub text: Option, + pub action: KeyAction, + pub modifiers: Modifiers, + pub consumed_modifiers: Modifiers, + pub composing: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyAction { + #[default] + Press, + Repeat, + Release, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Modifiers { + pub shift: bool, + pub alt: bool, + pub control: bool, + pub super_key: bool, +} + +/// The terminal modes which affect keyboard encoding. +/// +/// This copyable snapshot is the seam between Zeddy's background-owned output +/// parser and the window-thread-only Ghostty encoder. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct KeyboardModes { + cursor_key_application: bool, + keypad_key_application: bool, + disambiguate_escape_codes: bool, + report_event_types: bool, + report_alternate_keys: bool, + report_all_keys: bool, + report_associated_text: bool, +} + +/// Ghostty's keyboard encoder, kept separate because the safe binding is not +/// `Send` and must remain on the window thread which created it. +#[derive(Debug)] +pub struct KeyEncoder(key::Encoder<'static>); + +impl KeyEncoder { + pub fn new() -> Result { + Ok(Self(key::Encoder::new()?)) + } + + /// Encode one normalized event under an active terminal's mode snapshot. + pub fn encode( + &mut self, + input: &KeyEvent, + modes: KeyboardModes, + ) -> Result, KeyEncodingError> { + self.0 + .set_cursor_key_application(modes.cursor_key_application) + .set_keypad_key_application(modes.keypad_key_application) + .set_alt_esc_prefix(true) + .set_modify_other_keys_state_2(false) + .set_kitty_flags(kitty_flags(modes)) + .set_macos_option_as_alt(key::OptionAsAlt::True) + .set_backarrow_key_mode(false); + + let mut event = key::Event::new()?; + event + .set_action(match input.action { + KeyAction::Press => key::Action::Press, + KeyAction::Repeat => key::Action::Repeat, + KeyAction::Release => key::Action::Release, + }) + .set_key(input.code.upstream()) + .set_mods(key_modifiers(input.modifiers)) + .set_consumed_mods(key_modifiers(input.consumed_modifiers)) + .set_composing(input.composing); + if let Some(text) = &input.text { + event.set_utf8(Some(text.clone())); + } + if let Some(codepoint) = input + .code + .unshifted() + .or_else(|| input.text.as_ref().and_then(|text| text.chars().next())) + { + event.set_unshifted_codepoint(codepoint); + } + + let mut encoded = Vec::new(); + self.0.encode_to_vec(&event, &mut encoded)?; + Ok(encoded) + } +} /// A terminal grid, in cells. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -165,7 +356,9 @@ struct Emulation { impl Emulation { fn new(size: Size, scrolling_history: usize, title: TitleSink) -> Self { - let config = Config { scrolling_history, ..Config::default() }; + // This permits applications to negotiate Kitty keyboard modes. It does + // not enable any flag by itself; legacy encoding remains the default. + let config = Config { scrolling_history, kitty_keyboard: true, ..Config::default() }; Self { term: alacritty_terminal::Term::new(config, &size, title), parser: Processor::new() } } } @@ -257,6 +450,20 @@ impl Terminal { self.generation } + /// Take a copyable snapshot of the modes which affect keyboard encoding. + pub fn keyboard_modes(&self) -> KeyboardModes { + let mode = self.live.term.mode(); + KeyboardModes { + cursor_key_application: mode.contains(TermMode::APP_CURSOR), + keypad_key_application: mode.contains(TermMode::APP_KEYPAD), + disambiguate_escape_codes: mode.contains(TermMode::DISAMBIGUATE_ESC_CODES), + report_event_types: mode.contains(TermMode::REPORT_EVENT_TYPES), + report_alternate_keys: mode.contains(TermMode::REPORT_ALTERNATE_KEYS), + report_all_keys: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), + report_associated_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), + } + } + /// Replace the historical snapshot with ANSI-styled rows from Herdr, then /// apply the wheel movement that requested them. pub fn load_history(&mut self, ansi: &str, lines: i32, requested_at: u64) -> bool { @@ -289,6 +496,25 @@ impl Terminal { } } +fn key_modifiers(modifiers: Modifiers) -> key::Mods { + let mut result = key::Mods::empty(); + result.set(key::Mods::SHIFT, modifiers.shift); + result.set(key::Mods::ALT, modifiers.alt); + result.set(key::Mods::CTRL, modifiers.control); + result.set(key::Mods::SUPER, modifiers.super_key); + result +} + +fn kitty_flags(modes: KeyboardModes) -> key::KittyKeyFlags { + let mut flags = key::KittyKeyFlags::DISABLED; + flags.set(key::KittyKeyFlags::DISAMBIGUATE, modes.disambiguate_escape_codes); + flags.set(key::KittyKeyFlags::REPORT_EVENTS, modes.report_event_types); + flags.set(key::KittyKeyFlags::REPORT_ALTERNATES, modes.report_alternate_keys); + flags.set(key::KittyKeyFlags::REPORT_ALL, modes.report_all_keys); + flags.set(key::KittyKeyFlags::REPORT_ASSOCIATED, modes.report_associated_text); + flags +} + fn screen(term: &alacritty_terminal::Term, size: Size, title: &TitleSink) -> Screen { let grid = term.grid(); let mode = term.mode(); @@ -308,7 +534,7 @@ fn screen(term: &alacritty_terminal::Term, size: Size, title: &TitleS let visible = mode.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); let viewport_row = point.line.0.saturating_add(display_offset); (visible && viewport_row >= 0 && viewport_row < i32::from(size.rows)) - .then(|| Cursor { col: point.column.0 as u16, row: viewport_row as u16 }) + .then_some(Cursor { col: point.column.0 as u16, row: viewport_row as u16 }) }; Screen { size, rows, cursor, title: title.0.lock().expect("title mutex").clone() } @@ -377,6 +603,21 @@ mod tests { term.screen() } + fn press(code: KeyCode, text: Option<&str>, modifiers: Modifiers) -> KeyEvent { + KeyEvent { + code, + text: text.map(str::to_owned), + action: KeyAction::Press, + modifiers, + consumed_modifiers: Modifiers::default(), + composing: false, + } + } + + fn encoded(term: &Terminal, event: &KeyEvent) -> Vec { + KeyEncoder::new().unwrap().encode(event, term.keyboard_modes()).unwrap() + } + #[test] fn plain_text_lands_on_the_grid() { assert_eq!(screen_of(b"hello").to_text().lines().next(), Some("hello")); @@ -488,4 +729,72 @@ mod tests { fn a_zero_sized_grid_is_never_handed_to_the_emulator() { assert_eq!(Size::new(0, 0), Size::new(1, 1)); } + + #[test] + fn ghostty_encodes_the_legacy_terminal_key_matrix() { + let term = Terminal::new(Size::default()); + let alt = Modifiers { alt: true, ..Modifiers::default() }; + let shift = Modifiers { shift: true, ..Modifiers::default() }; + let control = Modifiers { control: true, ..Modifiers::default() }; + + for (event, expected) in [ + (press(KeyCode::Enter, None, Modifiers::default()), b"\r".to_vec()), + (press(KeyCode::Enter, None, shift), b"\x1b[27;2;13~".to_vec()), + (press(KeyCode::ArrowLeft, None, alt), b"\x1b[1;3D".to_vec()), + (press(KeyCode::ArrowRight, None, control), b"\x1b[1;5C".to_vec()), + (press(KeyCode::Tab, None, shift), b"\x1b[Z".to_vec()), + (press(KeyCode::F5, None, Modifiers::default()), b"\x1b[15~".to_vec()), + (press(KeyCode::B, Some("b"), alt), b"\x1bb".to_vec()), + ( + press( + KeyCode::Slash, + Some("?"), + Modifiers { control: true, shift: true, ..Modifiers::default() }, + ), + vec![0x7f], + ), + ] { + assert_eq!( + encoded(&term, &event), + expected, + "unexpected encoding for {:?} with {:?}", + event.code, + event.modifiers, + ); + } + } + + #[test] + fn application_cursor_mode_changes_unmodified_arrows() { + let mut term = Terminal::new(Size::default()); + let left = press(KeyCode::ArrowLeft, None, Modifiers::default()); + assert_eq!(encoded(&term, &left), b"\x1b[D"); + + term.feed(b"\x1b[?1h"); + assert_eq!(encoded(&term, &left), b"\x1bOD"); + } + + #[test] + fn kitty_mode_reports_modified_enter_and_key_releases() { + let mut term = Terminal::new(Size::default()); + // Disambiguate, report event types, and report every key. The latter is + // required by the Kitty protocol before Enter releases are reported. + term.feed(b"\x1b[>11u"); + let shifted_enter = + press(KeyCode::Enter, None, Modifiers { shift: true, ..Modifiers::default() }); + assert_eq!(encoded(&term, &shifted_enter), b"\x1b[13;2u"); + + let release = KeyEvent { action: KeyAction::Release, ..shifted_enter }; + assert_eq!(encoded(&term, &release), b"\x1b[13;2:3u"); + } + + #[test] + fn a_release_is_silent_until_an_application_requests_it() { + let term = Terminal::new(Size::default()); + let release = KeyEvent { + action: KeyAction::Release, + ..press(KeyCode::A, None, Modifiers::default()) + }; + assert!(encoded(&term, &release).is_empty()); + } } diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 3926da73..a5c57b0f 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -151,6 +151,13 @@ pub struct Zeddy { last_persisted: Option, title_bar: Entity, focus: FocusHandle, + /// Presses actually delivered to a terminal, keyed by GPUI's physical key + /// name. A matching release is sent only for one of these, so a release for + /// an application shortcut never leaks into a Kitty-aware TUI. + terminal_keys_down: HashMap, + /// libghostty's safe encoder is window-thread-bound. It is reconfigured + /// from the active session's copyable mode snapshot for every event. + key_encoder: zeddy_vt::KeyEncoder, problem: Option, } @@ -166,6 +173,8 @@ impl Zeddy { let command_palette_input = cx.new(|cx| TextInput::new("Type a command…", cx)); let rename_input = cx.new(|cx| TextInput::new("Type a name…", cx)); let title_bar = cx.new(|_| crate::title_bar::TitleBar::new("workspace-title-bar")); + let key_encoder = + zeddy_vt::KeyEncoder::new().expect("create the libghostty terminal key encoder"); cx.subscribe(&command_palette_input, |this, input, _: &InputEvent, cx| { this.command_palette_query = input.read(cx).text().to_owned(); this.command_palette_selected = 0; @@ -220,6 +229,8 @@ impl Zeddy { last_persisted: saved_json, title_bar, focus: cx.focus_handle(), + terminal_keys_down: HashMap::new(), + key_encoder, problem: Some(state_problem.unwrap_or_else(|| error.to_string())), }; } @@ -337,6 +348,8 @@ impl Zeddy { last_persisted: saved_json, title_bar, focus: cx.focus_handle(), + terminal_keys_down: HashMap::new(), + key_encoder, problem: state_problem.or(registry_problem), }; this.connect(cx); @@ -1567,12 +1580,47 @@ impl Zeddy { cx.notify(); return; } - let Some(bytes) = keys::bytes_for(&event.keystroke) else { + let key_name = event.keystroke.key.clone(); + if event.is_held && !self.terminal_keys_down.contains_key(&key_name) { + return; + } + let pressed = keys::normalize(&event.keystroke, event.is_held); + if self.send_terminal_key(&pressed, cx) && !event.is_held { + self.terminal_keys_down.insert(key_name, pressed); + } + } + + fn on_key_up(&mut self, event: &gpui::KeyUpEvent, cx: &mut Context) { + let Some(pressed) = self.terminal_keys_down.remove(&event.keystroke.key) else { return; }; - if let Some(space) = self.active.clone() { - space.update(cx, |space, cx| space.send_active(&bytes, cx)); + let released = keys::released(pressed); + self.send_terminal_key(&released, cx); + } + + /// Encode and deliver an event to the active terminal. Empty encodings are + /// intentionally not tracked: under the active protocol that key has no + /// matching release to deliver either. + fn send_terminal_key(&mut self, event: &zeddy_vt::KeyEvent, cx: &mut Context) -> bool { + let Some(space) = self.active.clone() else { + return false; + }; + let Some(modes) = space.read(cx).active_keyboard_modes() else { + return false; + }; + let bytes = match self.key_encoder.encode(event, modes) { + Ok(bytes) => bytes, + Err(error) => { + self.problem = Some(format!("encoding terminal input: {error}")); + cx.notify(); + return false; + } + }; + if bytes.is_empty() { + return false; } + space.update(cx, |space, cx| space.send_active(&bytes, cx)); + true } fn space_switcher(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { @@ -3218,6 +3266,7 @@ impl Render for Zeddy { this.toggle_command_palette(window, cx) })) .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) + .on_key_up(cx.listener(|this, event, _, cx| this.on_key_up(event, cx))) .child(title_bar) .child(body) .children(command_palette) diff --git a/crates/zeddy/src/keys.rs b/crates/zeddy/src/keys.rs index d94225b4..625d4dfa 100644 --- a/crates/zeddy/src/keys.rs +++ b/crates/zeddy/src/keys.rs @@ -1,147 +1,252 @@ -//! Turning a keystroke into the bytes a terminal expects. +//! The platform keyboard normalized for the VT boundary. //! -//! GPUI hands us a [`Keystroke`] — a key name and a set of modifiers. A PTY -//! wants bytes. This is the whole translation, kept in one file with tests -//! because it is the part of a terminal that is quietly wrong for years if -//! nobody checks it. -//! -//! Only the sequences an agent session actually needs are here: text, the -//! control range, the arrows and their bracketed forms, and the editing keys. -//! Mouse reporting, the kitty keyboard protocol, and application-cursor mode -//! are deliberately absent — none of them is reachable through herdr's frame -//! stream, which sends a re-render rather than the program's own output. - -use gpui::Keystroke; - -/// The bytes to send for a keystroke, or `None` for one that means nothing to -/// a terminal (a bare modifier, an unhandled function key). -pub fn bytes_for(keystroke: &Keystroke) -> Option> { - let modifiers = &keystroke.modifiers; - - let named = match keystroke.key.as_str() { - "enter" => Some("\r"), - "tab" if modifiers.shift => Some("\x1b[Z"), - "tab" => Some("\t"), - "backspace" => Some("\x7f"), - "escape" => Some("\x1b"), - "space" => Some(" "), - "up" => Some("\x1b[A"), - "down" => Some("\x1b[B"), - "right" => Some("\x1b[C"), - "left" => Some("\x1b[D"), - "home" => Some("\x1b[H"), - "end" => Some("\x1b[F"), - "pageup" => Some("\x1b[5~"), - "pagedown" => Some("\x1b[6~"), - "delete" => Some("\x1b[3~"), - "insert" => Some("\x1b[2~"), - _ => None, - }; +//! GPUI describes a keystroke with its platform key name, produced text and +//! modifiers. This module preserves those facts in Zeddy's normalized event; +//! `zeddy-vt` and libghostty decide which terminal bytes they mean. + +use gpui::{Keystroke, Modifiers as WindowModifiers}; +use zeddy_vt::{KeyAction, KeyCode, KeyEvent, Modifiers}; + +/// Normalize a key press or auto-repeat without choosing a terminal encoding. +pub fn normalize(keystroke: &Keystroke, held: bool) -> KeyEvent { + KeyEvent { + code: code_of(&keystroke.key), + text: typed(keystroke), + action: if held { KeyAction::Repeat } else { KeyAction::Press }, + modifiers: modifiers_of(keystroke.modifiers), + // GPUI does not currently expose consumed modifiers or IME composition + // state on a Keystroke. Keeping them explicit avoids inventing facts and + // leaves the encoder boundary ready when the platform API grows them. + consumed_modifiers: Modifiers::default(), + composing: false, + } +} + +/// Turn a press previously delivered to the terminal into its matching release. +pub fn released(mut pressed: KeyEvent) -> KeyEvent { + pressed.text = None; + pressed.action = KeyAction::Release; + pressed +} - if let Some(named) = named { - return Some(with_alt(named.as_bytes(), modifiers.alt)); +/// Text after Shift/layout processing but before Control/Alt transformations. +/// +/// Platforms commonly put the already-encoded C0 byte in `key_char` for +/// Control chords. Passing that through would decide the protocol before +/// Ghostty sees the event. Recover the printable physical character instead; +/// Ghostty can then choose C0, fixterms, modifyOtherKeys, or Kitty encoding. +fn typed(keystroke: &Keystroke) -> Option { + match keystroke.key_char.as_deref() { + Some(text) if !text.is_empty() && !text.chars().any(char::is_control) => { + Some(text.to_owned()) + } + Some(_) | None if keystroke.modifiers.control => physical_text(keystroke), + _ => None, } +} - // Control folds a letter into the C0 range: ^A is 1, ^Z is 26. The handful - // of punctuation controls follow the same table. - if modifiers.control { - let byte = match keystroke.key.as_str() { - key if key.len() == 1 => { - let c = key.chars().next().expect("one char"); - match c { - 'a'..='z' => Some(c as u8 - b'a' + 1), - '@' | ' ' => Some(0), - '[' => Some(27), - '\\' => Some(28), - ']' => Some(29), - '^' => Some(30), - '_' | '?' => Some(31), - _ => None, - } - } - _ => None, - }; - return byte.map(|byte| with_alt(&[byte], modifiers.alt)); +fn physical_text(keystroke: &Keystroke) -> Option { + if keystroke.key == "space" { + return Some(" ".to_owned()); } + let character = single(&keystroke.key)?; + let character = if keystroke.modifiers.shift { shifted_ascii(character) } else { character }; + Some(character.to_string()) +} - // Anything else is text, and GPUI already worked out what text it is — - // including the shifted and dead-key forms this code should not re-derive. - let text = keystroke.key_char.as_deref().filter(|text| !text.is_empty())?; - Some(with_alt(text.as_bytes(), modifiers.alt)) +/// The conventional shifted ASCII face of a physical key. This is needed only +/// when a platform replaced a Control chord's text with its C0 byte. +fn shifted_ascii(character: char) -> char { + match character { + 'a'..='z' => character.to_ascii_uppercase(), + '`' => '~', + '1' => '!', + '2' => '@', + '3' => '#', + '4' => '$', + '5' => '%', + '6' => '^', + '7' => '&', + '8' => '*', + '9' => '(', + '0' => ')', + '-' => '_', + '=' => '+', + '[' => '{', + ']' => '}', + '\\' => '|', + ';' => ':', + '\'' => '"', + ',' => '<', + '.' => '>', + '/' => '?', + _ => character, + } } -/// Alt is a leading escape. That is what a terminal means by "meta". -fn with_alt(bytes: &[u8], alt: bool) -> Vec { - if alt { - let mut out = Vec::with_capacity(bytes.len() + 1); - out.push(0x1b); - out.extend_from_slice(bytes); - out - } else { - bytes.to_vec() +fn code_of(key: &str) -> KeyCode { + match key { + "space" => KeyCode::Space, + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "escape" => KeyCode::Escape, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "up" => KeyCode::ArrowUp, + "down" => KeyCode::ArrowDown, + "left" => KeyCode::ArrowLeft, + "right" => KeyCode::ArrowRight, + "back" => KeyCode::BrowserBack, + "forward" => KeyCode::BrowserForward, + "copy" => KeyCode::Copy, + "cut" => KeyCode::Cut, + "paste" => KeyCode::Paste, + _ => function_key(key) + .or_else(|| single(key).and_then(KeyCode::typing)) + .unwrap_or(KeyCode::Unidentified), } } -#[cfg(test)] -mod tests { - use super::*; +fn function_key(key: &str) -> Option { + let number: u8 = key.strip_prefix('f')?.parse().ok()?; + Some(match number { + 1 => KeyCode::F1, + 2 => KeyCode::F2, + 3 => KeyCode::F3, + 4 => KeyCode::F4, + 5 => KeyCode::F5, + 6 => KeyCode::F6, + 7 => KeyCode::F7, + 8 => KeyCode::F8, + 9 => KeyCode::F9, + 10 => KeyCode::F10, + 11 => KeyCode::F11, + 12 => KeyCode::F12, + 13 => KeyCode::F13, + 14 => KeyCode::F14, + 15 => KeyCode::F15, + 16 => KeyCode::F16, + 17 => KeyCode::F17, + 18 => KeyCode::F18, + 19 => KeyCode::F19, + 20 => KeyCode::F20, + 21 => KeyCode::F21, + 22 => KeyCode::F22, + 23 => KeyCode::F23, + 24 => KeyCode::F24, + 25 => KeyCode::F25, + _ => return None, + }) +} - fn key(spec: &str) -> Keystroke { - Keystroke::parse(spec).expect("a parseable keystroke") - } +fn single(key: &str) -> Option { + let mut characters = key.chars(); + let first = characters.next()?; + characters.next().is_none().then_some(first) +} - fn typed(spec: &str, text: &str) -> Keystroke { - let mut keystroke = key(spec); - keystroke.key_char = Some(text.to_owned()); - keystroke +fn modifiers_of(modifiers: WindowModifiers) -> Modifiers { + Modifiers { + shift: modifiers.shift, + alt: modifiers.alt, + control: modifiers.control, + super_key: modifiers.platform, } +} - #[test] - fn plain_text_goes_through_as_itself() { - assert_eq!(bytes_for(&typed("a", "a")), Some(b"a".to_vec())); - assert_eq!(bytes_for(&typed("shift-a", "A")), Some(b"A".to_vec())); - } +#[cfg(test)] +mod tests { + use super::*; - #[test] - fn enter_is_a_carriage_return_and_not_a_newline() { - // A PTY in canonical mode reads CR as "submit"; LF would insert a line. - assert_eq!(bytes_for(&key("enter")), Some(b"\r".to_vec())); + fn keystroke(key: &str, text: Option<&str>) -> Keystroke { + Keystroke { + modifiers: WindowModifiers::default(), + key: key.to_owned(), + key_char: text.map(str::to_owned), + } } #[test] - fn backspace_is_del_which_is_what_readline_expects() { - assert_eq!(bytes_for(&key("backspace")), Some(vec![0x7f])); + fn names_printable_navigation_and_function_keys() { + assert_eq!(code_of("a"), KeyCode::A); + assert_eq!(code_of("/"), KeyCode::Slash); + assert_eq!(code_of("left"), KeyCode::ArrowLeft); + assert_eq!(code_of("f20"), KeyCode::F20); + assert_eq!(code_of("f25"), KeyCode::F25); + assert_eq!(code_of("f26"), KeyCode::Unidentified); } #[test] - fn control_letters_fold_into_the_c0_range() { - assert_eq!(bytes_for(&key("ctrl-a")), Some(vec![1])); - assert_eq!(bytes_for(&key("ctrl-c")), Some(vec![3])); - assert_eq!(bytes_for(&key("ctrl-z")), Some(vec![26])); + fn preserves_platform_text_instead_of_rederiving_it() { + let event = normalize(&keystroke("e", Some("é")), false); + assert_eq!(event.code, KeyCode::E); + assert_eq!(event.text.as_deref(), Some("é")); } #[test] - fn the_arrows_are_csi_sequences() { - assert_eq!(bytes_for(&key("up")), Some(b"\x1b[A".to_vec())); - assert_eq!(bytes_for(&key("left")), Some(b"\x1b[D".to_vec())); + fn recovers_printable_text_from_platform_control_bytes() { + let mut control_i = keystroke("i", Some("\t")); + control_i.modifiers.control = true; + assert_eq!(normalize(&control_i, false).text.as_deref(), Some("i")); + + let mut control_question = keystroke("/", Some("\u{7f}")); + control_question.modifiers.control = true; + control_question.modifiers.shift = true; + assert_eq!(normalize(&control_question, false).text.as_deref(), Some("?")); } #[test] - fn shift_tab_is_a_back_tab_and_not_a_tab() { - assert_eq!(bytes_for(&key("tab")), Some(b"\t".to_vec())); - assert_eq!(bytes_for(&key("shift-tab")), Some(b"\x1b[Z".to_vec())); + fn held_and_released_keys_keep_their_identity() { + let mut input = keystroke("left", None); + input.modifiers.alt = true; + let repeated = normalize(&input, true); + assert_eq!(repeated.action, KeyAction::Repeat); + assert!(repeated.modifiers.alt); + + let release = released(repeated); + assert_eq!(release.action, KeyAction::Release); + assert_eq!(release.code, KeyCode::ArrowLeft); + assert_eq!(release.text, None); + assert!(release.modifiers.alt); } #[test] - fn alt_prefixes_an_escape_whatever_the_key_was() { - assert_eq!(bytes_for(&typed("alt-b", "b")), Some(b"\x1bb".to_vec())); - assert_eq!(bytes_for(&key("alt-up")), Some(b"\x1b\x1b[A".to_vec())); - assert_eq!(bytes_for(&key("ctrl-alt-a")), Some(vec![0x1b, 1])); + fn the_original_missing_chords_reach_ghostty_intact() { + let mut encoder = zeddy_vt::KeyEncoder::new().unwrap(); + + let mut shifted_enter = keystroke("enter", Some("\n")); + shifted_enter.modifiers.shift = true; + let event = normalize(&shifted_enter, false); + assert_eq!( + encoder.encode(&event, zeddy_vt::KeyboardModes::default()).unwrap(), + b"\x1b[27;2;13~", + ); + + let mut option_left = keystroke("left", None); + option_left.modifiers.alt = true; + let event = normalize(&option_left, false); + assert_eq!( + encoder.encode(&event, zeddy_vt::KeyboardModes::default()).unwrap(), + b"\x1b[1;3D", + ); } #[test] - fn a_keystroke_with_no_text_and_no_name_sends_nothing() { - // An unhandled function key must send nothing rather than send garbage. - assert_eq!(bytes_for(&key("f13")), None); + fn control_i_remains_distinct_from_tab() { + let mut encoder = zeddy_vt::KeyEncoder::new().unwrap(); + let mut control_i = keystroke("i", Some("\t")); + control_i.modifiers.control = true; + + assert_eq!( + encoder + .encode(&normalize(&control_i, false), zeddy_vt::KeyboardModes::default()) + .unwrap(), + b"\x1b[105;5u", + ); } } diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 2772ee9c..8fe78271 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -27,7 +27,7 @@ use zeddy_herdr::{ control::{self, Client}, stream::{Frame, Input}, }; -use zeddy_vt::{Screen, ScrollResult, Size, Terminal}; +use zeddy_vt::{KeyboardModes, Screen, ScrollResult, Size, Terminal}; const HISTORY_LINES: u32 = 10_000; @@ -145,6 +145,11 @@ impl Session { self.input.lock().expect("session input mutex").send(bytes) } + /// Copy the active VT modes needed by the window-thread key encoder. + pub fn keyboard_modes(&self) -> KeyboardModes { + self.terminal.lock().expect("terminal mutex").keyboard_modes() + } + /// Tell the session how many cells it now has. /// /// A no-op at the same size, because a resize costs a full repaint and the diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index 1476b5e9..dbcd3e99 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -685,6 +685,12 @@ impl Space { } } + /// Keyboard mode state for the active terminal, or `None` for a plugin. + pub fn active_keyboard_modes(&self) -> Option { + let id = self.active()?; + self.items.get(&id)?.as_session().map(|item| item.session.keyboard_modes()) + } + pub fn fit_items(&mut self) { for item in self.items.values_mut() { let Some(item) = item.as_session_mut() else { diff --git a/docs/adr/0004-the-vt-core.md b/docs/adr/0004-the-vt-core.md index 45536eaf..6f18cef6 100644 --- a/docs/adr/0004-the-vt-core.md +++ b/docs/adr/0004-the-vt-core.md @@ -1,23 +1,32 @@ -# 0004 — alacritty's VT core, not libghostty +# 0004 — Alacritty output and Ghostty input behind one VT boundary ## Decision -`zeddy-vt` wraps `alacritty_terminal` — Zed's fork, at the revision Zed's own -terminal uses. Repaint bytes and optional ANSI host history go in; a `Screen` -comes out. +`zeddy-vt` wraps two terminal cores for different jobs. Zed's pinned +`alacritty_terminal` parses repaint bytes and optional ANSI host history into a +`Screen`. The pinned safe `libghostty-vt` binding turns normalized key events +into mode-aware terminal input bytes. Neither upstream vocabulary crosses the +crate boundary. ## Why -libghostty-vt is the faster parser and is what a terminal built for raw speed -would reach for. It also needs an exact Zig version and, on macOS, Xcode's Metal -toolchain, before `cargo build` does anything. zeddy's renderer is built on Zed's -frontend, and taking Zed's parser means the grid semantics the renderer assumes -and the grid semantics the parser produces already agree. - -The traffic zeddy parses is also not what that speed is for. herdr's frame -stream is a *re-render of its own emulated grid* — cell-addressed writes with -normalised SGR, at herdr's repaint rate — not the raw output of the program in -the PTY. The parser is not the bottleneck on that path. +Zeddy's renderer is built on Zed's frontend, and taking Zed's parser means the +grid semantics the renderer assumes and the grid semantics the parser produces +already agree. The traffic Zeddy parses is not where Ghostty's faster parser is +valuable. + +Keyboard encoding is different. Modified navigation, function keys, application +cursor/keypad modes, xterm extensions, fixterms, and the Kitty keyboard protocol +form a stateful protocol rather than a maintainable escape-sequence table. +Ghostty already implements that protocol and is also the encoder used by +chartr-rs. The Zig 0.16.0 build dependency is accepted for input fidelity; the +safe binding, Ghostty commit, and Zig version move as one deliberate pin. + +Herdr's frame stream remains a *re-render of its own emulated grid*, not the raw +output of the program in the PTY. Mode-aware encoding therefore uses every mode +the local parser can observe but cannot reconstruct modes Herdr omits. Legacy +Ghostty encoding is authoritative today; fully negotiated Kitty behavior +requires Herdr to carry structured keys or terminal mode state in the future. ## Snapshots, not borrows diff --git a/docs/adr/README.md b/docs/adr/README.md index a1349387..19f79b6f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,5 +8,5 @@ have to change for it to be worth revisiting. - [0001 — Sessions live in a private herdr](0001-a-private-herdr.md) - [0002 — The Zed layer, and what it costs](0002-the-zed-layer.md) - [0003 — Two plugin tiers](0003-two-plugin-tiers.md) -- [0004 — alacritty's VT core, not libghostty](0004-the-vt-core.md) +- [0004 — Alacritty output and Ghostty input behind one VT boundary](0004-the-vt-core.md) - [0005 — Spaces follow Zed's multi-workspace ownership](0005-spaces-follow-zed-multi-workspace.md) From 1da126793635ad2a0045c3f7885aeebba1db57e6 Mon Sep 17 00:00:00 2001 From: John Goh Date: Wed, 2 Sep 2026 14:29:06 +0800 Subject: [PATCH 028/110] Route terminal scrolling to full-screen TUIs --- crates/zeddy-vt/src/lib.rs | 235 +++++++++++++++++++++++++++++ crates/zeddy/src/app.rs | 11 +- crates/zeddy/src/session.rs | 59 +++++++- crates/zeddy/src/terminal.rs | 68 ++++++++- crates/zeddy/tests/live_session.rs | 47 +++++- docs/acceptance.md | 5 + docs/adr/0004-the-vt-core.md | 8 + 7 files changed, 419 insertions(+), 14 deletions(-) diff --git a/crates/zeddy-vt/src/lib.rs b/crates/zeddy-vt/src/lib.rs index dfd12e65..c736cb95 100644 --- a/crates/zeddy-vt/src/lib.rs +++ b/crates/zeddy-vt/src/lib.rs @@ -155,6 +155,37 @@ pub struct Modifiers { pub super_key: bool, } +/// A cell under a pointer, relative to the visible terminal grid. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CellPosition { + pub col: u16, + pub row: u16, +} + +impl CellPosition { + pub fn new(col: u16, row: u16) -> Self { + Self { col, row } + } +} + +/// One quantized vertical wheel movement over a terminal cell. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WheelEvent { + pub lines: i32, + pub position: CellPosition, + pub modifiers: Modifiers, +} + +/// Application-wheel behavior to use when a repaint stream omitted VT modes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WheelFallback { + /// Leave the gesture available to host scrollback. + #[default] + Scrollback, + /// Encode the gesture as an xterm SGR mouse report. + SgrMouse, +} + /// The terminal modes which affect keyboard encoding. /// /// This copyable snapshot is the seam between Zeddy's background-owned output @@ -464,6 +495,45 @@ impl Terminal { } } + /// Encode a wheel gesture when the application owns scrolling. + /// + /// Mouse tracking takes precedence. Otherwise xterm alternate-scroll mode + /// turns vertical wheel movement into application-cursor keys while the + /// alternate screen is active. Shift deliberately bypasses both so the + /// host terminal can expose its own scrollback. + pub fn wheel_input(&self, event: WheelEvent) -> Option> { + self.wheel_input_with_fallback(event, WheelFallback::Scrollback) + } + + /// Encode a wheel gesture, with a narrow fallback for mode-less repaint + /// streams whose control plane identifies a mouse-aware application. + pub fn wheel_input_with_fallback( + &self, + event: WheelEvent, + fallback: WheelFallback, + ) -> Option> { + if event.lines == 0 || event.modifiers.shift { + return None; + } + + let mode = self.live.term.mode(); + if mode.intersects(TermMode::MOUSE_MODE) { + // A legacy mouse encoding cannot represent every large-grid cell. + // An empty payload still means the application owns the gesture; + // it must not unexpectedly turn into host scrollback at an edge. + Some(mouse_wheel_input(event, *mode).unwrap_or_default()) + } else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) { + Some(alternate_scroll_input(event.lines)) + } else { + match fallback { + WheelFallback::Scrollback => None, + WheelFallback::SgrMouse => { + Some(mouse_wheel_input(event, TermMode::SGR_MOUSE).unwrap_or_default()) + } + } + } + } + /// Replace the historical snapshot with ANSI-styled rows from Herdr, then /// apply the wheel movement that requested them. pub fn load_history(&mut self, ansi: &str, lines: i32, requested_at: u64) -> bool { @@ -515,6 +585,58 @@ fn kitty_flags(modes: KeyboardModes) -> key::KittyKeyFlags { flags } +fn alternate_scroll_input(lines: i32) -> Vec { + let command = if lines > 0 { b'A' } else { b'B' }; + let mut bytes = Vec::with_capacity(lines.unsigned_abs() as usize * 3); + for _ in 0..lines.unsigned_abs() { + bytes.extend_from_slice(&[b'\x1b', b'O', command]); + } + bytes +} + +fn mouse_wheel_input(event: WheelEvent, mode: TermMode) -> Option> { + let button = if event.lines > 0 { 64 } else { 65 }; + let button = button + + u8::from(event.modifiers.shift) * 4 + + u8::from(event.modifiers.alt) * 8 + + u8::from(event.modifiers.control) * 16; + + let report = if mode.contains(TermMode::SGR_MOUSE) { + format!( + "\x1b[<{button};{};{}M", + u32::from(event.position.col) + 1, + u32::from(event.position.row) + 1, + ) + .into_bytes() + } else { + normal_mouse_report(event.position, button, mode.contains(TermMode::UTF8_MOUSE))? + }; + + Some(report.repeat(event.lines.unsigned_abs() as usize)) +} + +fn normal_mouse_report(position: CellPosition, button: u8, utf8: bool) -> Option> { + let max_position = if utf8 { 2015 } else { 223 }; + if position.col >= max_position || position.row >= max_position { + return None; + } + + let mut report = vec![b'\x1b', b'[', b'M', 32 + button]; + encode_mouse_position(&mut report, position.col, utf8); + encode_mouse_position(&mut report, position.row, utf8); + Some(report) +} + +fn encode_mouse_position(report: &mut Vec, position: u16, utf8: bool) { + let position = usize::from(position) + 33; + if utf8 && position >= 128 { + report.push((0xc0 + position / 64) as u8); + report.push((0x80 + (position & 63)) as u8); + } else { + report.push(position as u8); + } +} + fn screen(term: &alacritty_terminal::Term, size: Size, title: &TitleSink) -> Screen { let grid = term.grid(); let mode = term.mode(); @@ -774,6 +896,119 @@ mod tests { assert_eq!(encoded(&term, &left), b"\x1bOD"); } + #[test] + fn alternate_screen_wheel_gestures_become_application_cursor_keys() { + let mut term = Terminal::new(Size::default()); + let position = CellPosition::new(4, 2); + assert_eq!( + term.wheel_input(WheelEvent { lines: 1, position, modifiers: Modifiers::default() }), + None, + ); + + term.feed(b"\x1b[?1049h"); + assert_eq!( + term.wheel_input(WheelEvent { lines: 2, position, modifiers: Modifiers::default() }), + Some(b"\x1bOA\x1bOA".to_vec()), + ); + assert_eq!( + term.wheel_input(WheelEvent { lines: -1, position, modifiers: Modifiers::default() }), + Some(b"\x1bOB".to_vec()), + ); + } + + #[test] + fn shift_bypasses_application_wheel_input_for_host_scrollback() { + let mut term = Terminal::new(Size::default()); + term.feed(b"\x1b[?1049h\x1b[?1000h\x1b[?1006h"); + + assert_eq!( + term.wheel_input(WheelEvent { + lines: 1, + position: CellPosition::new(4, 2), + modifiers: Modifiers { shift: true, ..Modifiers::default() }, + }), + None, + ); + } + + #[test] + fn mouse_tracking_receives_sgr_wheel_reports_at_the_pointer_cell() { + let mut term = Terminal::new(Size::default()); + term.feed(b"\x1b[?1000h\x1b[?1006h"); + + assert_eq!( + term.wheel_input(WheelEvent { + lines: 2, + position: CellPosition::new(4, 2), + modifiers: Modifiers { control: true, ..Modifiers::default() }, + }), + Some(b"\x1b[<80;5;3M\x1b[<80;5;3M".to_vec()), + ); + assert_eq!( + term.wheel_input(WheelEvent { + lines: -1, + position: CellPosition::new(4, 2), + modifiers: Modifiers::default(), + }), + Some(b"\x1b[<65;5;3M".to_vec()), + ); + } + + #[test] + fn legacy_mouse_tracking_receives_wheel_reports_and_owns_unencodable_cells() { + let mut term = Terminal::new(Size::default()); + term.feed(b"\x1b[?1000h"); + + assert_eq!( + term.wheel_input(WheelEvent { + lines: 1, + position: CellPosition::new(4, 2), + modifiers: Modifiers::default(), + }), + Some(b"\x1b[M`%#".to_vec()), + ); + assert_eq!( + term.wheel_input(WheelEvent { + lines: 1, + position: CellPosition::new(300, 2), + modifiers: Modifiers::default(), + }), + Some(Vec::new()), + "mouse mode still owns positions its legacy encoding cannot represent", + ); + } + + #[test] + fn disabling_alternate_scroll_restores_host_scrollback() { + let mut term = Terminal::new(Size::default()); + term.feed(b"\x1b[?1049h\x1b[?1007l"); + + assert_eq!( + term.wheel_input(WheelEvent { + lines: 1, + position: CellPosition::default(), + modifiers: Modifiers::default(), + }), + None, + ); + } + + #[test] + fn sgr_fallback_restores_wheel_input_when_repaints_omit_modes() { + let term = Terminal::new(Size::default()); + let wheel = WheelEvent { + lines: 1, + position: CellPosition::new(4, 2), + modifiers: Modifiers::default(), + }; + + assert_eq!(term.wheel_input(wheel), None); + assert_eq!( + term.wheel_input_with_fallback(wheel, WheelFallback::SgrMouse), + Some(b"\x1b[<64;5;3M".to_vec()), + ); + } + #[test] fn kitty_mode_reports_modified_enter_and_key_releases() { let mut term = Terminal::new(Size::default()); diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index a5c57b0f..af2dbfdd 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -3442,7 +3442,16 @@ fn terminal( let Some(lines) = fit.wheel_lines(event) else { return; }; - if session.scroll(lines) { + let Some(position) = fit.cell_at(event.position) else { + return; + }; + let modifiers = zeddy_vt::Modifiers { + shift: event.modifiers.shift, + alt: event.modifiers.alt, + control: event.modifiers.control, + super_key: event.modifiers.platform, + }; + if session.wheel(zeddy_vt::WheelEvent { lines, position, modifiers }) { window.refresh(); } cx.stop_propagation(); diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 8fe78271..5a30e324 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -27,7 +27,7 @@ use zeddy_herdr::{ control::{self, Client}, stream::{Frame, Input}, }; -use zeddy_vt::{KeyboardModes, Screen, ScrollResult, Size, Terminal}; +use zeddy_vt::{KeyboardModes, Screen, ScrollResult, Size, Terminal, WheelEvent, WheelFallback}; const HISTORY_LINES: u32 = 10_000; @@ -199,13 +199,24 @@ impl SessionAccess { self.input.lock().expect("session input mutex").send(bytes) } - pub fn scroll(&self, lines: i32) -> bool { - match self.terminal.lock().expect("terminal mutex").scroll(lines) { + pub fn wheel(&self, event: WheelEvent) -> bool { + let mut terminal = self.terminal.lock().expect("terminal mutex"); + if let Some(bytes) = terminal.wheel_input_with_fallback(event, wheel_fallback(&self.info)) { + drop(terminal); + if !bytes.is_empty() { + let _ = self.input.lock().expect("session input mutex").send(&bytes); + } + return false; + } + + let result = terminal.scroll(event.lines); + drop(terminal); + match result { ScrollResult::Changed => true, ScrollResult::Unchanged => false, ScrollResult::NeedsHistory => { let mut pending = self.pending_scroll.lock().expect("pending scroll mutex"); - *pending = pending.saturating_add(lines); + *pending = pending.saturating_add(event.lines); drop(pending); self.fetch_history(); false @@ -244,6 +255,24 @@ impl SessionAccess { } } +/// Herdr's repaint protocol currently omits mouse and alternate-screen modes. +/// Keep the workaround deliberately scoped to agents verified to use SGR +/// mouse input; ordinary foreground processes must retain host scrollback. +fn wheel_fallback(info: &control::Session) -> WheelFallback { + info.agent + .as_deref() + .into_iter() + .chain(info.running.as_deref()) + .any(is_mouse_aware_full_tui) + .then_some(WheelFallback::SgrMouse) + .unwrap_or(WheelFallback::Scrollback) +} + +fn is_mouse_aware_full_tui(name: &str) -> bool { + let compact: String = name.chars().filter(|character| character.is_alphanumeric()).collect(); + matches!(compact.to_ascii_lowercase().as_str(), "claude" | "claudecode" | "opencode" | "codex") +} + impl std::fmt::Debug for Session { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Session") @@ -284,11 +313,33 @@ fn apply_frame(terminal: &mut Terminal, frame: &Frame) -> bool { mod tests { use super::*; + fn info(agent: Option<&str>, running: Option<&str>) -> control::Session { + control::Session { + id: PaneId("pane".to_owned()), + workspace: zeddy_herdr::WorkspaceId("workspace".to_owned()), + label: "shell".to_owned(), + running: running.map(str::to_owned), + status: control::SessionStatus::Unknown, + agent: agent.map(str::to_owned), + cwd: None, + } + } + #[test] fn the_two_grid_types_round_trip() { assert_eq!(size_of(geometry(Size::new(120, 40))), Size::new(120, 40)); } + #[test] + fn only_verified_mouse_aware_full_tuis_use_the_mode_less_fallback() { + for name in ["Claude", "Claude Code", "OpenCode", "codex"] { + assert_eq!(wheel_fallback(&info(Some(name), None)), WheelFallback::SgrMouse); + assert_eq!(wheel_fallback(&info(None, Some(name))), WheelFallback::SgrMouse); + } + assert_eq!(wheel_fallback(&info(None, Some("npm run dev"))), WheelFallback::Scrollback,); + assert_eq!(wheel_fallback(&info(None, None)), WheelFallback::Scrollback); + } + #[test] fn a_frame_for_the_current_grid_is_applied() { let mut terminal = Terminal::new(Size::new(120, 40)); diff --git a/crates/zeddy/src/terminal.rs b/crates/zeddy/src/terminal.rs index 94ab5b96..78347c60 100644 --- a/crates/zeddy/src/terminal.rs +++ b/crates/zeddy/src/terminal.rs @@ -20,10 +20,10 @@ use std::{cell::Cell as StdCell, rc::Rc}; use gpui::{ App, Bounds, Element, ElementId, Font, FontWeight, GlobalElementId, Hsla, InspectorElementId, - IntoElement, LayoutId, Pixels, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, - fill, point, px, size, + IntoElement, LayoutId, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, + Window, fill, point, px, size, }; -use zeddy_vt::{Screen, Size}; +use zeddy_vt::{CellPosition, Screen, Size}; /// The grid the last paint found room for. /// @@ -37,6 +37,8 @@ use zeddy_vt::{Screen, Size}; pub struct Fit { size: Rc>>, line_height: Rc>>, + bounds: Rc>>>, + cell_width: Rc>>, scroll_px: Rc>, } @@ -49,11 +51,31 @@ impl Fit { self.size.replace(Some(size)) != Some(size) } - fn measure(&self, size: Size, line_height: Pixels) -> bool { + fn measure( + &self, + size: Size, + bounds: Bounds, + cell_width: Pixels, + line_height: Pixels, + ) -> bool { + self.bounds.set(Some(bounds)); + self.cell_width.set(Some(cell_width)); self.line_height.set(Some(line_height)); self.set(size) } + /// Resolve a window-space pointer position to the nearest visible cell. + pub fn cell_at(&self, position: Point) -> Option { + let bounds = self.bounds.get()?; + let cell_width = self.cell_width.get()?; + let line_height = self.line_height.get()?; + let size = self.size.get()?; + let local = position - bounds.origin; + let col = (local.x / cell_width).floor().clamp(0., f32::from(size.cols - 1)) as u16; + let row = (local.y / line_height).floor().clamp(0., f32::from(size.rows - 1)) as u16; + Some(CellPosition::new(col, row)) + } + /// Quantize a wheel or trackpad gesture into terminal lines. /// /// Pixel deltas accumulate until they cross a full row, while traditional @@ -189,7 +211,7 @@ impl Element for TerminalElement { (bounds.size.width / cell.width).floor() as u16, (bounds.size.height / cell.height).floor() as u16, ); - let fit_changed = self.fit.measure(measured, cell.height); + let fit_changed = self.fit.measure(measured, bounds, cell.width, cell.height); if fit_changed { // `Window::refresh` is intentionally ignored while GPUI is in a // draw pass. Defer it until the pass completes so the next render @@ -331,7 +353,12 @@ mod tests { #[test] fn wheel_deltas_are_measured_in_terminal_lines() { let fit = Fit::default(); - fit.measure(Size::new(80, 24), px(20.)); + fit.measure( + Size::new(80, 24), + Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), + px(10.), + px(20.), + ); let event = gpui::ScrollWheelEvent { delta: gpui::ScrollDelta::Lines(point(0., 2.)), ..Default::default() @@ -343,7 +370,12 @@ mod tests { #[test] fn trackpad_pixels_accumulate_to_complete_rows() { let fit = Fit::default(); - fit.measure(Size::new(80, 24), px(20.)); + fit.measure( + Size::new(80, 24), + Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), + px(10.), + px(20.), + ); let event = |pixels| gpui::ScrollWheelEvent { delta: gpui::ScrollDelta::Pixels(point(px(0.), px(pixels))), ..Default::default() @@ -356,7 +388,12 @@ mod tests { #[test] fn a_trackpad_gestures_first_delta_is_not_dropped() { let fit = Fit::default(); - fit.measure(Size::new(80, 24), px(20.)); + fit.measure( + Size::new(80, 24), + Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), + px(10.), + px(20.), + ); let event = gpui::ScrollWheelEvent { delta: gpui::ScrollDelta::Pixels(point(px(0.), px(20.))), touch_phase: gpui::TouchPhase::Started, @@ -365,4 +402,19 @@ mod tests { assert_eq!(fit.wheel_lines(&event), Some(1)); } + + #[test] + fn pointer_positions_resolve_to_bounded_terminal_cells() { + let fit = Fit::default(); + fit.measure( + Size::new(80, 24), + Bounds::new(point(px(10.), px(20.)), size(px(800.), px(480.))), + px(10.), + px(20.), + ); + + assert_eq!(fit.cell_at(point(px(35.), px(65.))), Some(CellPosition::new(2, 2))); + assert_eq!(fit.cell_at(point(px(0.), px(0.))), Some(CellPosition::new(0, 0))); + assert_eq!(fit.cell_at(point(px(900.), px(600.))), Some(CellPosition::new(79, 23))); + } } diff --git a/crates/zeddy/tests/live_session.rs b/crates/zeddy/tests/live_session.rs index 81458019..a98e0223 100644 --- a/crates/zeddy/tests/live_session.rs +++ b/crates/zeddy/tests/live_session.rs @@ -13,7 +13,7 @@ use std::{ }; use zeddy_herdr::{Geometry, Namespace, Sidecar, control::Client}; -use zeddy_vt::{Size, Terminal}; +use zeddy_vt::{CellPosition, Modifiers, Size, Terminal, WheelEvent, WheelFallback}; struct Live { client: Client, @@ -163,6 +163,51 @@ fn scrollback_survives_the_real_frame_stream() { assert!(!history.contains("scroll-40"), "scrolling did not move away from the live viewport"); } +#[test] +#[ignore = "needs a real herdr daemon"] +fn full_screen_wheel_fallback_survives_the_real_frame_stream() { + let live = Live::start(); + let client = &live.client; + let workspace = + client.open_workspace(&std::env::temp_dir(), Some("zeddy-wheel-modes")).expect("workspace"); + let session = client.start_session(&workspace, None).expect("session"); + let size = Size::new(80, 24); + let attachment = + client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); + let (mut frames, mut input) = attachment.split(); + let mut terminal = Terminal::new(size); + + input + .send(b"printf '\\033[?1049h\\033[?1000h\\033[?1006hfull-tui-marker'\r") + .expect("enter full-screen mouse mode"); + + for _ in 0..20 { + let frame = frames.next_frame().expect("the stream stays valid").expect("a frame"); + if frame.full { + terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); + } + terminal.feed(&frame.bytes); + if terminal.screen().to_text().contains("full-tui-marker") { + break; + } + } + + assert!(terminal.screen().to_text().contains("full-tui-marker")); + let wheel = + WheelEvent { lines: 1, position: CellPosition::new(4, 2), modifiers: Modifiers::default() }; + assert_eq!( + terminal.wheel_input(wheel), + None, + "Herdr's repaint stream currently omits the application's mouse mode", + ); + assert_eq!( + terminal.wheel_input_with_fallback(wheel, WheelFallback::SgrMouse), + Some(b"\x1b[<64;5;3M".to_vec()), + "the control-plane fallback must restore the application's wheel input", + ); + let _ = client.close_session(&session.id); +} + #[test] #[ignore = "needs a real herdr daemon"] fn a_broken_transport_recovers_without_resurrecting_dead_sessions() { diff --git a/docs/acceptance.md b/docs/acceptance.md index 4efbc2a6..f826c069 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -65,6 +65,11 @@ small pixel deltas accumulate smoothly, colors survive in history, new output does not pull a historical viewport to the bottom, and scrolling up again after returning to the prompt refreshes the host history. +Open OpenCode, Claude Code, and Codex and scroll in both directions with a wheel +and a trackpad. Confirm each application viewport moves instead of host history. +Then hold Shift while scrolling and confirm the gesture reaches host scrollback +instead. + Confirm the active-space picker sits in the macOS title bar immediately after the traffic lights, and the chevron menu sits at the far-right corner in both chrome modes. Sidebar mode offers `Switch to Tabbed mode`, a diff --git a/docs/adr/0004-the-vt-core.md b/docs/adr/0004-the-vt-core.md index 6f18cef6..72e01caf 100644 --- a/docs/adr/0004-the-vt-core.md +++ b/docs/adr/0004-the-vt-core.md @@ -28,6 +28,14 @@ the local parser can observe but cannot reconstruct modes Herdr omits. Legacy Ghostty encoding is authoritative today; fully negotiated Kitty behavior requires Herdr to carry structured keys or terminal mode state in the future. +The same limitation affects wheel input: repaint frames omit alternate-screen +and mouse-tracking modes. Zeddy therefore uses observed modes when present and +a deliberately narrow control-plane fallback for Claude Code, OpenCode, and +Codex, whose foreground identities Herdr preserves and whose full-screen TUIs +request xterm SGR mouse input. Shift always bypasses application wheel handling +to expose host scrollback. Other foreground processes remain on host scrollback +rather than receiving guessed escape sequences. + ## Snapshots, not borrows `Terminal::screen` copies. A borrowed grid would be faster and would tie the From 9d6351c67401434e9145fa903d76241a7ae893db Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 00:37:14 +0800 Subject: [PATCH 029/110] Adopt Zed's terminal stack for Herdr sessions Replace the custom VT renderer with Zed's terminal model and view, preserve Chartr-specific hosting behavior, and pin Herdr's semantic mouse forwarding with live daemon handoff. --- .plan/maps/chartr-zeddy-workspace/spec.md | 30 +- Cargo.lock | 4543 ++++++++++++++++- Cargo.toml | 58 +- LICENSE-GPL | 200 + README.md | 29 +- crates/zeddy-herdr/Cargo.toml | 1 - crates/zeddy-herdr/src/control.rs | 207 +- crates/zeddy-herdr/src/lib.rs | 77 +- crates/zeddy-herdr/src/namespace.rs | 2 +- crates/zeddy-herdr/src/protocol.rs | 116 +- crates/zeddy-herdr/src/sidecar.rs | 4 +- crates/zeddy-herdr/src/stream.rs | 294 -- crates/zeddy-vt/Cargo.toml | 11 - crates/zeddy-vt/src/lib.rs | 1035 ---- crates/zeddy/Cargo.toml | 9 +- crates/zeddy/src/actions.rs | 89 +- crates/zeddy/src/app.rs | 627 ++- crates/zeddy/src/chrome.rs | 63 +- crates/zeddy/src/chrome/sidebar.rs | 4 +- crates/zeddy/src/chrome/tabs.rs | 2 +- crates/zeddy/src/fonts.rs | 50 +- crates/zeddy/src/item.rs | 34 +- crates/zeddy/src/keys.rs | 252 - crates/zeddy/src/main.rs | 17 +- crates/zeddy/src/palette.rs | 199 - crates/zeddy/src/session.rs | 405 +- crates/zeddy/src/settings.rs | 7 +- crates/zeddy/src/settings_window.rs | 27 +- crates/zeddy/src/space.rs | 255 +- crates/zeddy/src/terminal.rs | 420 -- crates/zeddy/src/terminal_host.rs | 156 + crates/zeddy/src/text_input.rs | 2 +- crates/zeddy/tests/live_session.rs | 171 +- docs/acceptance.md | 46 +- docs/adr/0001-a-private-herdr.md | 17 +- docs/adr/0004-the-vt-core.md | 62 - docs/adr/0004-the-zed-terminal-stack.md | 77 + docs/adr/README.md | 2 +- vendor/herdr/fetch.sh | 96 +- vendor/zed-terminal-view/CHARTR-PATCH.md | 19 + vendor/zed-terminal-view/Cargo.toml | 48 + vendor/zed-terminal-view/LICENSE-GPL | 1 + vendor/zed-terminal-view/README.md | 37 + vendor/zed-terminal-view/rustfmt.toml | 3 + .../scripts/print256color.sh | 96 + vendor/zed-terminal-view/scripts/truecolor.sh | 19 + vendor/zed-terminal-view/src/persistence.rs | 507 ++ .../zed-terminal-view/src/terminal_element.rs | 3011 +++++++++++ .../zed-terminal-view/src/terminal_panel.rs | 2581 ++++++++++ .../src/terminal_path_like_target.rs | 1013 ++++ .../src/terminal_scrollbar.rs | 87 + vendor/zed-terminal-view/src/terminal_view.rs | 3301 ++++++++++++ 52 files changed, 16788 insertions(+), 3631 deletions(-) create mode 100644 LICENSE-GPL delete mode 100644 crates/zeddy-herdr/src/stream.rs delete mode 100644 crates/zeddy-vt/Cargo.toml delete mode 100644 crates/zeddy-vt/src/lib.rs delete mode 100644 crates/zeddy/src/keys.rs delete mode 100644 crates/zeddy/src/palette.rs delete mode 100644 crates/zeddy/src/terminal.rs create mode 100644 crates/zeddy/src/terminal_host.rs delete mode 100644 docs/adr/0004-the-vt-core.md create mode 100644 docs/adr/0004-the-zed-terminal-stack.md create mode 100644 vendor/zed-terminal-view/CHARTR-PATCH.md create mode 100644 vendor/zed-terminal-view/Cargo.toml create mode 120000 vendor/zed-terminal-view/LICENSE-GPL create mode 100644 vendor/zed-terminal-view/README.md create mode 100644 vendor/zed-terminal-view/rustfmt.toml create mode 100755 vendor/zed-terminal-view/scripts/print256color.sh create mode 100755 vendor/zed-terminal-view/scripts/truecolor.sh create mode 100644 vendor/zed-terminal-view/src/persistence.rs create mode 100644 vendor/zed-terminal-view/src/terminal_element.rs create mode 100644 vendor/zed-terminal-view/src/terminal_panel.rs create mode 100644 vendor/zed-terminal-view/src/terminal_path_like_target.rs create mode 100644 vendor/zed-terminal-view/src/terminal_scrollbar.rs create mode 100644 vendor/zed-terminal-view/src/terminal_view.rs diff --git a/.plan/maps/chartr-zeddy-workspace/spec.md b/.plan/maps/chartr-zeddy-workspace/spec.md index 50f29267..8d349af2 100644 --- a/.plan/maps/chartr-zeddy-workspace/spec.md +++ b/.plan/maps/chartr-zeddy-workspace/spec.md @@ -67,7 +67,7 @@ semantic action/keymap system, atomic persistence, live updates, and plugin page contributions. Settings are user-global in this version. Restore Chartr-rs's small, explicit Herdr lifecycle: fresh control connections, -per-session stream failure states, one clean backend restart, a crash-loop guard, +per-session attach-client failure states, one clean backend restart, a crash-loop guard, and a non-destructive Retry action. Avoid a generic supervisor or backend administration surface. @@ -158,7 +158,7 @@ configuration automatically. 78. As a Chartr user, I want semantic theme colors and Zed UI components everywhere, so that alternate themes remain coherent. 79. As a keyboard user, I want every drag operation to have an action-based alternative, so that pane management is not pointer-only. 80. As an accessibility user, I want reliable focus order, focus restoration, labels, contrast, and reduced-motion behavior, so that the application is operable without visual guesswork. -81. As a Chartr user, I want an affected terminal to show a clear state when its Herdr stream breaks, so that a transport failure is understandable. +81. As a Chartr user, I want an affected terminal to show a clear state when its Herdr attach client closes, so that a transport failure is understandable. 82. As a Chartr user, I want reattachment offered only when Herdr confirms the same session exists, so that retry cannot silently create or target the wrong session. 83. As a Chartr user, I want Chartr to restart its private Herdr once after unexpected death, so that a transient backend crash recovers automatically. 84. As a Chartr user, I want repeated backend death to become a stable crash-loop state with Retry, so that Chartr does not restart forever. @@ -170,7 +170,7 @@ configuration automatically. 90. As an existing Chartr user, I want Chartr-zeddy data isolated from older installations, so that the rewrite cannot corrupt or conflict with existing settings. 91. As a Chartr user, I want to drag-sort every sidebar space, including Free sessions and recovered folders, so that the cockpit order matches my workflow and survives relaunch. 92. As an accessibility user, I want Reduce Motion to disable space-sort settling without disabling direct manipulation, so that reordering remains usable with less animation. -93. As a Chartr user, I want wheel and trackpad gestures to move through Herdr's host scrollback, so that output remains reviewable after it leaves the live viewport. +93. As a Chartr user, I want Zed's wheel and trackpad behavior and terminal-owned scrollback, so that shell output and alternate-screen TUIs respond like a modern terminal. ## Implementation Decisions @@ -193,11 +193,11 @@ configuration automatically. refresh: display agent, internal agent, non-shell foreground process, then persistent tab label/number. Exiting a process restores the fallback rather than leaving a stale locally remembered title. -- Terminal wheel deltas are accumulated in row units. The first upward gesture - loads ANSI-styled `pane.read` host history on a background thread and moves a - separate historical VT viewport; live repaint frames remain isolated from - history so they cannot manufacture duplicate or missing rows. New live output - marks a bottomed history snapshot for refresh, and resizing invalidates it. +- Zed's pinned `terminal` model and `TerminalView` own emulation, rendering, + input, selection, clipboard, IME, mouse reporting, resizing, and scrollback as + one unit. Its local PTY runs Herdr's native attach client while Herdr retains + the persistent PTY and process lifetime. Chartr has no parallel VT or history + implementation. - An opened item entity may appear in only one outer workspace tab, pane, and space. Moving an item removes it from its source before insertion; an emptied outer tab disappears. Cross-space moves are absent. @@ -303,8 +303,8 @@ configuration automatically. Bundled examples move with the contract; incompatible plugins fail clearly. - Herdr control requests use a fresh Unix connection and exact handshake. There is no long-lived reconnecting control client. -- A stream error removes the terminal command channel and renders an actionable - notice inside that item. Reattach is offered only after confirming the stable +- An attach-client exit renders an actionable notice inside that item. Reattach + is offered only after confirming the stable session identity through the control plane. - A small window-owned health state machine checks the private daemon, performs one clean replacement, and detects a second failure within 60 seconds as a crash @@ -361,10 +361,12 @@ configuration automatically. - Web plugin tests cover real view hosting, safe project read/write, canonical and symlink containment, folderless storage, unsafe per-plugin access, declared network/process/session actions, permission display, and immediate revocation. -- Herdr unit tests cover protocol framing and lifecycle transitions. Required live - tests launch the vendored private backend and cover handshake, shell painting, - close/kill, detach/adopt, broken stream, confirmed reattach, one backend restart, - and crash-loop Retry. Transport completion requires these live tests to pass. +- Herdr unit tests cover protocol framing, namespace-safe attach specifications, + and lifecycle transitions. Required live tests launch the vendored private + backend and cover handshake, terminal identity, direct attachment, and clean + replacement after a hard crash. OS clipboard, IME, pointer, scrollback, and + alternate-screen behavior remain release interaction checks because they + require a real GPUI window and native input devices. - Visual acceptance captures Chartr Dark and Light at common window sizes for tabbed mode, both sidebar submodes, nested panes, drag targets, empty panes, confirmations, errors, settings, command palette, and plugin permissions. diff --git a/Cargo.lock b/Cargo.lock index ca0400b5..f57eb0d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,11 +78,11 @@ checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" dependencies = [ "accesskit", "accesskit_atspi_common", - "async-channel", + "async-channel 2.5.0", "async-executor", "async-task", "atspi", - "futures-lite", + "futures-lite 2.6.1", "futures-util", "serde", "zbus", @@ -129,6 +129,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "agent_settings" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "convert_case 0.11.0", + "fs", + "futures", + "gpui", + "language_model", + "log", + "paths", + "project", + "regex", + "schemars", + "serde", + "settings", + "util", +] + [[package]] name = "ahash" version = "0.8.12" @@ -208,6 +230,12 @@ dependencies = [ "libc", ] +[[package]] +name = "any_vec" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" + [[package]] name = "anyhow" version = "1.0.104" @@ -249,6 +277,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -276,6 +310,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -299,6 +339,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "askpass" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "futures", + "gpui", + "log", + "net", + "smol", + "tempfile", + "util", + "which 6.0.3", + "windows 0.61.3", + "zeroize", +] + [[package]] name = "assets" version = "0.1.0" @@ -315,12 +373,23 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.2", "event-listener-strategy", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -353,8 +422,8 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.5.0", + "futures-lite 2.6.1", "pin-project-lite", "slab", ] @@ -367,7 +436,22 @@ checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", - "futures-lite", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite 2.6.1", + "once_cell", ] [[package]] @@ -380,7 +464,7 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "parking", "polling", "rustix 1.1.4", @@ -394,7 +478,7 @@ version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] @@ -407,24 +491,23 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite", + "futures-lite 2.6.1", ] [[package]] name = "async-process" version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +source = "git+https://github.com/zed-industries/async-process.git?rev=0b6d6713570af61806e1e5cb40e0f757cb93fd9d#0b6d6713570af61806e1e5cb40e0f757cb93fd9d" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", - "futures-lite", + "event-listener 5.4.2", + "futures-lite 2.6.1", "rustix 1.1.4", ] @@ -457,11 +540,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-tar" +version = "0.6.1" +source = "git+https://github.com/zed-industries/async-tar?rev=bd3ad6f89df9a9da7a8535958756d6bf465936a0#bd3ad6f89df9a9da7a8535958756d6bf465936a0" +dependencies = [ + "async-std", + "filetime", + "futures-core", + "libc", + "redox_syscall 0.7.5", + "xattr", +] + [[package]] name = "async-task" version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +source = "git+https://github.com/smol-rs/async-task.git?rev=b4486cd71e4e94fbda54ce6302444de14f4d190e#b4486cd71e4e94fbda54ce6302444de14f4d190e" [[package]] name = "async-trait" @@ -474,6 +596,38 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "async_zip" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "async-compression", + "crc32fast", + "futures-lite 2.6.1", + "pin-project", + "thiserror 2.0.20", +] + [[package]] name = "atk" version = "0.18.2" @@ -595,6 +749,29 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -622,6 +799,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.71.1" @@ -759,10 +942,10 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "piper", ] @@ -776,6 +959,16 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "breadcrumbs" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "ui", + "workspace", +] + [[package]] name = "bstr" version = "1.13.1" @@ -786,6 +979,25 @@ dependencies = [ "serde_core", ] +[[package]] +name = "buffer_diff" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "clock", + "gpui", + "imara-diff", + "language", + "log", + "pretty_assertions", + "rope", + "sum_tree", + "text", + "tracing", + "util", + "ztracing", +] + [[package]] name = "built" version = "0.8.1" @@ -797,6 +1009,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "by_address" @@ -950,7 +1165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -974,6 +1189,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.45" @@ -989,123 +1215,302 @@ dependencies = [ ] [[package]] -name = "cipher" -version = "0.4.4" +name = "chunked_transfer" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", - "zeroize", -] +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] -name = "clang-sys" -version = "1.9.1" +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "glob", - "libc", - "libloading", + "ciborium-io", + "ciborium-ll", + "serde", ] [[package]] -name = "cocoa" -version = "0.25.0" +name = "ciborium-io" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation 0.1.2", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "foreign-types", - "libc", - "objc", -] +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] -name = "cocoa" -version = "0.26.0" +name = "ciborium-ll" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ - "bitflags 2.13.1", - "block", - "cocoa-foundation 0.2.1", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "foreign-types", - "libc", - "objc", + "ciborium-io", + "half", ] [[package]] -name = "cocoa-foundation" -version = "0.1.2" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", + "crypto-common 0.1.7", + "inout", + "zeroize", ] [[package]] -name = "cocoa-foundation" -version = "0.2.1" +name = "circular-buffer" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "objc", -] +checksum = "a77b57ca5f8cc834a94656d3186a8c515a7ba9026af03cf7694c29908b169205" [[package]] -name = "codespan-reporting" -version = "0.13.1" +name = "clang-sys" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ - "serde", - "termcolor", - "unicode-width", + "glob", + "libc", + "libloading", ] [[package]] -name = "collections" +name = "client" version = "0.1.0" source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" dependencies = [ - "gpui_util", - "indexmap", - "rustc-hash 2.1.3", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "combine" -version = "4.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" -dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-tungstenite", + "chrono", + "clock", + "cloud_api_client", + "cloud_api_types", + "cloud_llm_client", + "collections", + "credentials_provider", + "derive_more", + "feature_flags", + "fs", + "futures", + "gpui", + "gpui_tokio", + "http_client", + "http_client_tls", + "log", + "objc2-foundation 0.3.2", + "parking_lot", + "paths", + "postage", + "proxy_handshake", + "rand 0.9.5", + "regex", + "release_channel", + "rpc", + "rustls-pki-types", + "semver", + "serde", + "serde_json", + "serde_urlencoded", + "settings", + "sha2 0.10.9", + "smol", + "telemetry", + "telemetry_events", + "text", + "thiserror 2.0.20", + "time", + "tiny_http", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "url", + "util", + "windows 0.61.3", + "worktree", + "zed_credentials_provider", +] + +[[package]] +name = "clock" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "serde", + "smallvec", +] + +[[package]] +name = "cloud_api_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-lock", + "cloud_api_types", + "futures", + "gpui", + "gpui_tokio", + "http_client", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.20", + "yawc", +] + +[[package]] +name = "cloud_api_types" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "chrono", + "ciborium", + "cloud_llm_client", + "serde", + "serde_json", + "strum", + "zeta_prompt", +] + +[[package]] +name = "cloud_llm_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "strum", + "uuid", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.1", + "block", + "cocoa-foundation 0.2.1", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ "bytes", "memchr", ] +[[package]] +name = "command-fds" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" +dependencies = [ + "nix 0.31.3", + "thiserror 2.0.20", +] + [[package]] name = "component" version = "0.1.0" @@ -1127,6 +1532,7 @@ checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "bzip2", "compression-core", + "deflate64", "flate2", "memchr", ] @@ -1156,6 +1562,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1182,6 +1594,58 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "context_server" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "collections", + "futures", + "futures-lite 1.13.0", + "gpui", + "http_client", + "log", + "net", + "oauth_callback_server", + "parking_lot", + "postage", + "rand 0.9.5", + "schemars", + "serde", + "serde_json", + "settings", + "sha2 0.10.9", + "slotmap", + "tempfile", + "url", + "util", +] + [[package]] name = "convert_case" version = "0.8.0" @@ -1254,7 +1718,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1267,7 +1731,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1280,7 +1744,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1327,7 +1791,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1396,6 +1860,144 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6835dba958b2ab7ab523e7e99296e0524317f60430a00cf5850562ef78ea7001" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6e4ce8ee6d899381fbdd9e6561336c651189d46cecaeee09b29e8d80aa786e" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb6d37015df7ea4b60450c1229ad5f5819a1fb27434b063f8e6216dfbd0c42a" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986bea0b0858b55192782120032ce9c15943fa073f186f6e479653c59e62c329" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f30aeb2de7f97d6f26b4a1642615834daad58e2e4d7c027810010a3a32f22be" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash 2.1.3", + "serde", + "smallvec", + "target-lexicon 0.13.5", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5dd137fcdedef33b6fd40edf1ced024460d764ceb75833e8198a843395945c" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab54b260ef23a8f0f536679b9fc3b3b3e05353e8d1448f3ab83df02078e8be9b" + +[[package]] +name = "cranelift-control" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3e569779ad70537f34a670d444ee3d75ae583b2023913f4682814b0979f7e8" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff53acc85f5c5f7d9315ff133a6671d329a0f04aa2d1a8a2e81d59709ccddcb" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5976c0ff5bfadf61cd8bda81fea78ee5a07018b9cd03e66c0952c56684928b" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-isle" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b4f73d2288e9480fd2d1d9ab576394dce4805443d6148c6d819dbf78865ce4" + +[[package]] +name = "cranelift-native" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9650c2baf22fa1e2542a5bdd8152616ec2023d929c4cbb450ff677ad8d9c21" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.123.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad4f61ae701d73c326d3df08c366b29ad10f1ba06c245092f217b8d2306746b" + [[package]] name = "crc32fast" version = "1.5.1" @@ -1405,6 +2007,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "credentials_provider" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "gpui", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1473,19 +2084,42 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros 0.6.1", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.7.0", "dtoa-short", "itoa", "phf 0.13.1", "smallvec", ] +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "cssparser-macros" version = "0.7.0" @@ -1512,17 +2146,105 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dap" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-compression", + "async-tar", + "async-trait", + "client", + "collections", + "dap-types", + "fs", + "futures", + "gpui", + "http_client", + "language", + "log", + "node_runtime", + "parking_lot", + "paths", + "proto", + "schemars", + "serde", + "serde_json", + "settings", + "smallvec", + "smol", + "task", + "telemetry", + "util", +] + +[[package]] +name = "dap-types" +version = "0.0.1" +source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "db" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "gpui", + "indoc", + "inventory", + "log", + "paths", + "release_channel", + "sqlez", + "sqlez_macros", + "util", + "uuid", + "zed_env_vars", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive_more" @@ -1557,6 +2279,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "diffy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" +dependencies = [ + "nu-ansi-term", +] + [[package]] name = "digest" version = "0.10.7" @@ -1564,6 +2301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1575,7 +2313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1680,12 +2418,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set 0.8.0", - "cssparser", + "cssparser 0.37.0", "foldhash 0.2.0", - "html5ever", + "html5ever 0.39.0", "precomputed-hash", - "selectors", - "tendril", + "selectors 0.38.0", + "tendril 0.5.1", ] [[package]] @@ -1709,6 +2447,30 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dugong" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e042d0154d5b36580c5151b45baeb008e5fdb00a918ea203e85f24f2c921e6" +dependencies = [ + "dugong-graphlib", + "rustc-hash 2.1.3", + "serde", + "serde_json", +] + +[[package]] +name = "dugong-graphlib" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219fd4382e892b3eb905d02a7cfb30ca60a7dad662a554b4aebf056f270c7649" +dependencies = [ + "hashbrown 0.17.1", + "rustc-hash 2.1.3", + "serde", + "serde_json", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1733,6 +2495,95 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ec4rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b31a881d38439026e3d5dd938ab20328d36e23caca8fd5981c42e4b677f5842" + +[[package]] +name = "edit_prediction_types" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "client", + "gpui", + "icons", + "language", + "text", +] + +[[package]] +name = "editor" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "aho-corasick", + "anyhow", + "assets", + "base64 0.22.1", + "breadcrumbs", + "buffer_diff", + "client", + "clock", + "collections", + "convert_case 0.11.0", + "dap", + "db", + "edit_prediction_types", + "emojis", + "feature_flags", + "file_icons", + "fs", + "futures", + "futures-lite 1.13.0", + "fuzzy", + "git", + "gpui", + "indoc", + "itertools 0.14.0", + "language", + "linkify", + "log", + "lsp", + "markdown", + "menu", + "multi_buffer", + "ordered-float 2.10.1", + "parking_lot", + "pretty_assertions", + "project", + "rand 0.9.5", + "regex", + "rope", + "rpc", + "schemars", + "serde", + "serde_json", + "settings", + "smallvec", + "snippet", + "sum_tree", + "task", + "telemetry", + "text", + "theme", + "theme_settings", + "ui", + "ui_input", + "unicode-script", + "unicode-segmentation", + "url", + "urlencoding", + "util", + "uuid", + "vim_mode_setting", + "workspace", + "zed_actions", + "zlog", + "ztracing", +] + [[package]] name = "either" version = "1.18.0" @@ -1753,6 +2604,27 @@ dependencies = [ "winreg", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "emojis" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1800,6 +2672,14 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "env_var" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui_shared_string", +] + [[package]] name = "equator" version = "0.4.2" @@ -1844,7 +2724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1866,6 +2746,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.2" @@ -1882,7 +2768,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.2", "pin-project-lite", ] @@ -1903,17 +2789,72 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "extension" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-trait", + "cloud_api_types", + "collections", + "dap", + "fs", + "futures", + "gpui", + "heck 0.5.0", + "http_client", + "language", + "log", + "lsp", + "parking_lot", + "path", + "proto", + "semver", + "serde", + "serde_json", + "task", + "toml 0.8.23", + "tracing", + "url", + "util", + "wasm-encoder 0.252.0", + "wasmparser 0.252.0", + "which 6.0.3", + "ztracing", +] + [[package]] name = "fallible-iterator" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "fastrand" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] [[package]] name = "fastrand" @@ -1939,6 +2880,31 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "feature_flags" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "collections", + "feature_flags_macros", + "fs", + "gpui", + "inventory", + "schemars", + "serde_json", + "settings", +] + +[[package]] +name = "feature_flags_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1949,6 +2915,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file_icons" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "theme", + "util", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1960,12 +2936,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2007,7 +2999,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "fastrand", + "fastrand 2.5.0", "futures-core", "futures-sink", "spin 0.9.9", @@ -2072,6 +3064,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2079,7 +3080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2093,6 +3094,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2119,6 +3126,62 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-std", + "async-tar", + "async-trait", + "collections", + "dunce", + "futures", + "git", + "gpui", + "ignore", + "is_executable", + "libc", + "log", + "notify", + "parking_lot", + "path", + "paths", + "proto", + "rope", + "rustix 1.1.4", + "serde", + "serde_json", + "slotmap", + "smol", + "telemetry", + "tempfile", + "text", + "thiserror 2.0.20", + "trash", + "unicode-normalization", + "util", + "windows 0.61.3", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.34" @@ -2150,9 +3213,9 @@ version = "7.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "futures-core", - "futures-lite", + "futures-lite 2.6.1", "pin-project", "smallvec", ] @@ -2180,13 +3243,28 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", + "fastrand 2.5.0", "futures-core", "futures-io", "parking", @@ -2233,6 +3311,29 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "gpui_util", + "log", + "path", +] + +[[package]] +name = "fuzzy_nucleo" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "fuzzy", + "gpui", + "gpui_util", + "nucleo", + "path", +] + [[package]] name = "gdk" version = "0.18.2" @@ -2345,8 +3446,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2391,6 +3494,11 @@ name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] [[package]] name = "gio" @@ -2424,6 +3532,61 @@ dependencies = [ "winapi", ] +[[package]] +name = "git" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "askpass", + "async-channel 2.5.0", + "async-trait", + "collections", + "derive_more", + "futures", + "gpui", + "http_client", + "itertools 0.14.0", + "log", + "parking_lot", + "regex", + "rope", + "schemars", + "serde", + "smallvec", + "smol", + "sum_tree", + "text", + "thiserror 2.0.20", + "time", + "url", + "urlencoding", + "util", + "uuid", + "ztracing", +] + +[[package]] +name = "git_hosting_providers" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "git", + "gpui", + "http_client", + "itertools 0.14.0", + "regex", + "serde", + "serde_json", + "settings", + "url", + "urlencoding", + "util", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2501,6 +3664,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.17.0" @@ -2574,7 +3749,7 @@ source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e dependencies = [ "accesskit", "anyhow", - "async-channel", + "async-channel 2.5.0", "async-task", "backtrace", "bindgen", @@ -2652,7 +3827,7 @@ dependencies = [ "core-video", "derive_more", "etagere", - "foreign-types", + "foreign-types 0.5.0", "gpui", "image", "log", @@ -2718,7 +3893,7 @@ dependencies = [ "core-text", "ctor", "dispatch2", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui_apple", @@ -2727,7 +3902,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", - "mach2", + "mach2 0.5.0", "media", "metal", "objc", @@ -2779,6 +3954,17 @@ dependencies = [ "smol_str", ] +[[package]] +name = "gpui_tokio" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "gpui", + "gpui_util", + "tokio", +] + [[package]] name = "gpui_util" version = "0.1.0" @@ -2786,7 +3972,7 @@ source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e dependencies = [ "anyhow", "log", - "which", + "which 6.0.3", ] [[package]] @@ -2867,6 +4053,16 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "granit-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60388b03522b86e24b6b45952255279936b08a871775156fc89c94e792d21d8" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2969,6 +4165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2987,6 +4184,13 @@ 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", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -3007,6 +4211,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -3064,6 +4277,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -3071,7 +4298,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", - "markup5ever", + "markup5ever 0.39.0", +] + +[[package]] +name = "htmlize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e815d50d9e411ba2690d730e6ec139c08260dddb756df315dbd16d01a587226" +dependencies = [ + "memchr", + "pastey", + "phf 0.13.1", + "phf_codegen 0.13.1", + "serde_json", ] [[package]] @@ -3094,6 +4334,19 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "http_client" version = "0.1.0" @@ -3101,6 +4354,8 @@ source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e dependencies = [ "anyhow", "async-compression", + "async-fs", + "async-tar", "bytes", "derive_more", "futures", @@ -3111,7 +4366,19 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "sha2 0.10.9", + "tempfile", "url", + "util", +] + +[[package]] +name = "http_client_tls" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "rustls", + "rustls-platform-verifier", ] [[package]] @@ -3120,6 +4387,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.14" @@ -3129,6 +4402,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3162,6 +4469,31 @@ dependencies = [ "strum", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collator_data", + "icu_collections", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" + [[package]] name = "icu_collections" version = "2.3.0" @@ -3184,11 +4516,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -3200,6 +4553,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -3238,6 +4594,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3266,6 +4624,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -3305,6 +4679,16 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "imgref" version = "1.12.3" @@ -3323,6 +4707,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3334,15 +4747,12 @@ dependencies = [ ] [[package]] -name = "int-enum" -version = "1.2.0" +name = "instant" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366a1634cccc76b4cfd3e7580de9b605e4d93f1edac48d786c1f867c0def495" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.119", + "cfg-if", ] [[package]] @@ -3396,6 +4806,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3508,6 +4936,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3520,21 +4958,187 @@ dependencies = [ ] [[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "kurbo" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop-util" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884f3e747ed2dcee867cda1b0c31a048f9e20de2d916a248949319921a2e666e" + +[[package]] +name = "language" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-trait", + "chardetng", + "clock", + "collections", + "diffy", + "ec4rs", + "encoding_rs", + "fs", + "futures", + "futures-lite 1.13.0", + "fuzzy_nucleo", + "globset", + "gpui", + "http_client", + "imara-diff", + "itertools 0.14.0", + "language_core", + "log", + "lsp", + "parking_lot", + "postage", + "regex", + "rpc", + "semver", + "serde", + "serde_json", + "settings", + "shellexpand", + "smallvec", + "streaming-iterator", + "strsim", + "sum_tree", + "task", + "text", + "theme", + "toml 0.8.23", + "tracing", + "tree-sitter", + "unicase", + "util", + "watch", + "zlog", + "ztracing", +] + +[[package]] +name = "language_core" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "collections", + "gpui_shared_string", + "gpui_util", + "log", + "parking_lot", + "path", + "regex", + "schemars", + "serde", + "serde_json", + "strum", + "toml 0.8.23", + "tree-sitter", +] + +[[package]] +name = "language_model" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "base64 0.22.1", + "collections", + "credentials_provider", + "env_var", + "futures", + "gpui", + "gpui_util", + "http_client", + "icons", + "image", + "language_model_core", + "log", + "parking_lot", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "language_model_core" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" dependencies = [ - "arrayvec", - "euclid", - "polycool", - "smallvec", + "anyhow", + "async-lock", + "cloud_llm_client", + "futures", + "gpui_shared_string", + "http_client", + "log", + "partial-json-fixer", + "schemars", + "serde", + "serde_json", + "strum", + "thiserror 2.0.20", ] [[package]] @@ -3542,6 +5146,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.9", +] [[package]] name = "leak" @@ -3558,6 +5165,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3586,21 +5199,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libghostty-vt" -version = "0.2.1" -source = "git+https://github.com/Uzaaft/libghostty-rs?rev=de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec#de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec" -dependencies = [ - "bitflags 2.13.1", - "int-enum", - "libghostty-vt-sys", -] - -[[package]] -name = "libghostty-vt-sys" -version = "0.2.1" -source = "git+https://github.com/Uzaaft/libghostty-rs?rev=de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec#de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec" - [[package]] name = "libloading" version = "0.8.9" @@ -3649,6 +5247,15 @@ version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linktime-proc-macro" version = "0.2.3" @@ -3698,6 +5305,25 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lol_html" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5adbb62638edf7e6bc88835cd3ea388bdd53382af42045da0e414ea78aa0c91a" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "memchr", + "mime", + "precomputed-hash", + "selectors 0.37.0", + "thiserror 2.0.20", +] + [[package]] name = "loop9" version = "0.1.5" @@ -3707,6 +5333,40 @@ dependencies = [ "imgref", ] +[[package]] +name = "lsp" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "collections", + "futures", + "futures-lite 1.13.0", + "gpui", + "gpui_util", + "log", + "lsp-types", + "parking_lot", + "postage", + "release_channel", + "schemars", + "serde", + "serde_json", + "util", +] + +[[package]] +name = "lsp-types" +version = "0.95.1" +source = "git+https://github.com/zed-industries/lsp-types?rev=f4dfa89a21ca35cd929b70354b1583fabae325f8#f4dfa89a21ca35cd929b70354b1583fabae325f8" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "url", +] + [[package]] name = "lyon" version = "1.0.19" @@ -3759,6 +5419,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -3773,6 +5439,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.5.0" @@ -3791,6 +5466,58 @@ dependencies = [ "libc", ] +[[package]] +name = "manatee" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9741945d2b000ec5182e0c35edcff22cb592a3eda4e6efe34663718e3e54617f" +dependencies = [ + "indexmap", + "libm", + "rustc-hash 2.1.3", + "thiserror 2.0.20", +] + +[[package]] +name = "markdown" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "base64 0.22.1", + "collections", + "gpui", + "html5ever 0.27.0", + "language", + "linkify", + "log", + "markup5ever_rcdom", + "mermaid_render", + "pulldown-cmark", + "settings", + "smallvec", + "stacksafe", + "sum_tree", + "theme", + "theme_settings", + "ui", + "util", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -3798,10 +5525,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", - "tendril", + "tendril 0.5.1", "web_atoms", ] +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever 0.27.0", + "markup5ever 0.12.1", + "tendril 0.4.3", + "xml5ever", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3831,7 +5570,7 @@ dependencies = [ "bindgen", "core-foundation 0.10.1", "core-video", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -3842,6 +5581,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.11" @@ -3868,6 +5616,86 @@ dependencies = [ "gpui", ] +[[package]] +name = "mermaid_render" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "gpui", + "merman", + "quick-xml 0.38.4", + "serde_json", + "tracing", + "ztracing", +] + +[[package]] +name = "merman" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263b2e923bce36d7e5d799af599e762f0160f0e6f3a397564e913ef35d7718f6" +dependencies = [ + "merman-core", + "merman-render", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "merman-core" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae7b72e61bbc56c7bebb94ef0fa831886d66057d17298338b2a3cac6ab71def" +dependencies = [ + "euclid", + "granit-parser", + "htmlize", + "indexmap", + "json5", + "lalrpop-util", + "lol_html", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "thiserror 2.0.20", + "unicode-general-category", + "url", +] + +[[package]] +name = "merman-render" +version = "0.8.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd17bfdd6866df20f34a8697eba00e0b86efa734de0b6a629b07a755d748fd68" +dependencies = [ + "base64 0.22.1", + "cssparser 0.36.0", + "data-url", + "dugong", + "icu_collator", + "icu_locale_core", + "indexmap", + "kurbo", + "libm", + "manatee", + "merman-core", + "pulldown-cmark", + "quick-xml 0.41.0", + "roughr-merman", + "roxmltree 0.21.1", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "svgtypes", + "thiserror 2.0.20", + "unicode-linebreak", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "metal" version = "0.33.0" @@ -3877,12 +5705,30 @@ dependencies = [ "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", ] +[[package]] +name = "migrator" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "convert_case 0.11.0", + "log", + "serde_json", + "serde_json_lenient", + "settings_content", + "settings_json", + "streaming-iterator", + "tree-sitter", + "tree-sitter-json", +] + [[package]] name = "mime" version = "0.3.17" @@ -3925,6 +5771,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "miow" version = "0.6.1" @@ -3944,6 +5802,43 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multi_buffer" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "buffer_diff", + "clock", + "collections", + "ctor", + "futures-lite 1.13.0", + "gpui", + "itertools 0.14.0", + "language", + "log", + "parking_lot", + "rand 0.9.5", + "rope", + "serde", + "settings", + "smallvec", + "sum_tree", + "text", + "theme", + "tracing", + "tree-sitter", + "unicode-segmentation", + "util", + "ztracing", +] + +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "naga" version = "29.0.4" @@ -3970,6 +5865,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3994,12 +5906,46 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "net" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "async-io", + "smol", + "windows 0.61.3", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -4009,6 +5955,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "node_runtime" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-compression", + "async-std", + "async-tar", + "async-trait", + "chrono", + "futures", + "http_client", + "log", + "paths", + "semver", + "serde", + "serde_json", + "smol", + "util", + "watch", + "which 6.0.3", +] + [[package]] name = "nom" version = "7.1.3" @@ -4034,13 +6004,33 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "9.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7768eb7201a1964d8eb10224e850df046ab584ee3c3d26e340a993d81f748148" +dependencies = [ + "bitflags 2.13.1", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", + "xxhash-rust", +] + [[package]] name = "notify-rust" version = "4.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" dependencies = [ - "futures-lite", + "futures-lite 2.6.1", "log", "mac-notification-sys", "serde", @@ -4048,6 +6038,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -4066,6 +6065,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -4090,6 +6110,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-bigint-dig" version = "0.9.1" @@ -4216,6 +6252,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "oauth_callback_server" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "futures", + "log", + "tiny_http", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -4336,6 +6393,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4376,6 +6443,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -4489,6 +6566,9 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", "memchr", ] @@ -4523,14 +6603,14 @@ dependencies = [ "cipher", "digest 0.10.7", "endi", - "futures-lite", + "futures-lite 2.6.1", "futures-util", "getrandom 0.4.3", "hkdf", "hmac", "md-5", "num", - "num-bigint-dig", + "num-bigint-dig 0.9.1", "pbkdf2", "serde", "serde_bytes", @@ -4552,6 +6632,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "optfield" version = "0.4.0" @@ -4569,6 +6692,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -4666,11 +6798,17 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "partial-json-fixer" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f084e464d66716c43ec8530ab9df0f8fa211f419320f135a4b4291fa62f62956" + [[package]] name = "paste" version = "1.0.15" @@ -4683,6 +6821,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "path" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "dunce", + "serde", +] + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -4702,6 +6850,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "paths" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "const_format", + "dirs", + "ignore", + "util", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -4712,6 +6871,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4728,6 +6896,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -4749,6 +6936,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" @@ -4759,13 +6956,23 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + [[package]] name = "phf_generator" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" dependencies = [ - "fastrand", + "fastrand 2.5.0", "phf_shared 0.12.1", ] @@ -4775,7 +6982,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand", + "fastrand 2.5.0", "phf_shared 0.13.1", ] @@ -4805,6 +7012,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -4855,6 +7071,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -4862,10 +7084,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.5.0", "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4933,6 +7176,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4965,12 +7217,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5001,6 +7267,36 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettier" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "fs", + "gpui", + "language", + "log", + "lsp", + "node_runtime", + "parking_lot", + "paths", + "serde", + "serde_json", + "util", +] + +[[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" @@ -5072,18 +7368,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "version_check", -] - [[package]] name = "profiling" version = "1.0.18" @@ -5103,6 +7387,86 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "project" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "aho-corasick", + "anyhow", + "askpass", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "buffer_diff", + "circular-buffer", + "client", + "clock", + "collections", + "context_server", + "credentials_provider", + "dap", + "encoding_rs", + "extension", + "fancy-regex", + "fs", + "futures", + "fuzzy", + "fuzzy_nucleo", + "git", + "git_hosting_providers", + "globset", + "gpui", + "http_client", + "image", + "indexmap", + "itertools 0.14.0", + "language", + "log", + "lsp", + "markdown", + "node_runtime", + "parking_lot", + "path", + "paths", + "percent-encoding", + "postage", + "prettier", + "rand 0.9.5", + "regex", + "release_channel", + "remote", + "rpc", + "schemars", + "semver", + "serde", + "serde_json", + "settings", + "sha2 0.10.9", + "shellexpand", + "smallvec", + "smol", + "snippet", + "snippet_provider", + "sum_tree", + "task", + "tempfile", + "terminal", + "text", + "toml 0.8.23", + "tracing", + "url", + "util", + "watch", + "wax", + "which 6.0.3", + "worktree", + "zed_credentials_provider", + "zeroize", + "zlog", + "ztracing", +] + [[package]] name = "proptest" version = "1.10.0" @@ -5133,6 +7497,83 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62941722fb675d463659e49c4f3fe1fe792ff24fe5bbaa9c08cd3b98a1c354f5" +dependencies = [ + "bytes", + "heck 0.3.3", + "itertools 0.10.5", + "lazy_static", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", + "which 4.4.2", +] + +[[package]] +name = "prost-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cc1a3263e07e0bf68e96268f37665207b49560d98739662cdfaae215c720fe" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "proto" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "proxy_handshake" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "base64 0.22.1", + "httparse", + "percent-encoding", + "thiserror 2.0.20", + "tokio", + "url", +] + [[package]] name = "psm" version = "0.1.32" @@ -5143,6 +7584,40 @@ dependencies = [ "cc", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "pulley-interpreter" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0a4b56042e461cc64456650182938e2d1ede98fa0c8a975027416a2809c414" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "244667bea2e214273442a71f26adb12b88a41f66718fb2c6eea47c00f0dc325f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pulp" version = "0.22.3" @@ -5193,6 +7668,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -5437,6 +7921,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5476,6 +7969,20 @@ dependencies = [ "derive_refineable", ] +[[package]] +name = "regalloc2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash 2.1.3", + "smallvec", +] + [[package]] name = "regex" version = "1.13.1" @@ -5505,6 +8012,48 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "release_channel" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "semver", +] + +[[package]] +name = "remote" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "askpass", + "async-trait", + "base64 0.22.1", + "collections", + "fs", + "futures", + "gpui", + "log", + "parking_lot", + "paths", + "prost", + "release_channel", + "rpc", + "schemars", + "semver", + "serde", + "serde_json", + "settings", + "smol", + "telemetry", + "tempfile", + "thiserror 2.0.20", + "urlencoding", + "util", + "which 6.0.3", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -5551,6 +8100,33 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rope" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "heapless", + "log", + "rayon", + "sum_tree", + "tracing", + "unicode-segmentation", + "util", + "ztracing", +] + +[[package]] +name = "roughr-merman" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a1b0137cf48e915300be3fe4cf9f8f9a2af242ebf41b6f8fa716c2fd482f00b" +dependencies = [ + "euclid", + "num-traits", + "palette", + "svgtypes", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5566,6 +8142,50 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpc" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-tungstenite", + "base64 0.22.1", + "collections", + "futures", + "gpui", + "parking_lot", + "proto", + "rand 0.9.5", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "strum", + "tracing", + "util", + "zstd", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig 0.8.6", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -5653,7 +8273,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5666,7 +8286,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5686,6 +8306,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -5695,6 +8316,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -5704,12 +8337,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5757,6 +8418,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -5766,6 +8433,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduler" version = "0.1.0" @@ -5843,6 +8519,48 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags 2.13.1", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.3", + "servo_arc", + "smallvec", +] + [[package]] name = "selectors" version = "0.38.0" @@ -5850,12 +8568,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.13.1", - "cssparser", + "cssparser 0.37.0", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.3", "servo_arc", @@ -5965,6 +8683,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -6015,6 +8744,103 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "session" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "db", + "gpui", + "serde_json", + "util", + "uuid", +] + +[[package]] +name = "settings" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "ec4rs", + "fs", + "futures", + "gpui", + "inventory", + "log", + "migrator", + "paths", + "release_channel", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings_content", + "settings_json", + "settings_macros", + "smallvec", + "util", + "zlog", +] + +[[package]] +name = "settings_content" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "derive_more", + "gpui", + "language_model_core", + "log", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings_json", + "settings_macros", + "strum", + "util", +] + +[[package]] +name = "settings_json" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "serde_json_lenient", + "serde_path_to_error", + "tree-sitter", + "tree-sitter-json", + "util", +] + +[[package]] +name = "settings_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6052,6 +8878,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -6084,6 +8919,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6154,6 +8999,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smol" @@ -6161,7 +9009,7 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-executor", "async-fs", "async-io", @@ -6169,7 +9017,7 @@ dependencies = [ "async-net", "async-process", "blocking", - "futures-lite", + "futures-lite 2.6.1", ] [[package]] @@ -6182,6 +9030,46 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snippet" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "smallvec", +] + +[[package]] +name = "snippet_provider" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "extension", + "fs", + "futures", + "gpui", + "parking_lot", + "paths", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "snippet", + "util", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "soup3" version = "0.5.0" @@ -6235,6 +9123,55 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlez" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "futures", + "indoc", + "libsqlite3-sys", + "log", + "parking_lot", + "pollster 0.4.0", + "sqlformat", + "thread_local", + "util", + "uuid", +] + +[[package]] +name = "sqlez_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "sqlez", + "sqlformat", + "syn 2.0.119", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom 7.1.3", + "unicode_categories", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6281,6 +9218,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -6290,6 +9233,19 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -6302,6 +9258,18 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "string_cache_codegen" version = "0.6.1" @@ -6314,6 +9282,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -6466,6 +9440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -6533,6 +9508,20 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -6558,6 +9547,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + [[package]] name = "tao-core-video-sys" version = "0.2.0" @@ -6587,6 +9582,35 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "task" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "futures", + "gpui", + "hex", + "log", + "parking_lot", + "proto", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "sha2 0.10.9", + "shellexpand", + "util", + "zed_actions", +] + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -6598,17 +9622,48 @@ dependencies = [ "windows-version", ] +[[package]] +name = "telemetry" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "futures", + "serde_json", + "telemetry_events", +] + +[[package]] +name = "telemetry_events" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "semver", + "serde", + "serde_json", +] + [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", ] [[package]] @@ -6629,6 +9684,94 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "alacritty_terminal", + "anyhow", + "async-channel 2.5.0", + "collections", + "futures", + "futures-lite 1.13.0", + "gpui", + "itertools 0.14.0", + "libc", + "log", + "parking_lot", + "percent-encoding", + "regex", + "release_channel", + "schemars", + "serde", + "settings", + "sysinfo 0.37.2", + "task", + "theme", + "theme_settings", + "thiserror 2.0.20", + "url", + "urlencoding", + "util", + "vte", + "windows 0.61.3", +] + +[[package]] +name = "terminal_view" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-recursion", + "breadcrumbs", + "collections", + "db", + "dirs", + "editor", + "futures", + "gpui", + "itertools 0.14.0", + "language", + "log", + "menu", + "pretty_assertions", + "project", + "regex", + "schemars", + "serde", + "serde_json", + "settings", + "shellexpand", + "shlex 1.3.0", + "task", + "terminal", + "theme", + "theme_settings", + "ui", + "util", + "workspace", + "zed_actions", +] + +[[package]] +name = "text" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "clock", + "collections", + "log", + "parking_lot", + "postage", + "regex", + "rope", + "smallvec", + "sum_tree", + "util", +] + [[package]] name = "theme" version = "0.1.0" @@ -6650,6 +9793,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "theme_settings" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "collections", + "gpui", + "gpui_util", + "log", + "palette", + "refineable", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings", + "theme", + "uuid", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6720,7 +9884,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -6778,6 +9944,18 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -6785,6 +9963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6803,6 +9982,66 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -6999,6 +10238,58 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trash" +version = "5.2.5" +source = "git+https://github.com/zed-industries/trash-rs?rev=41c6c800d884a89351f3b8856d12894cccee261d#41c6c800d884a89351f3b8856d12894cccee261d" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", + "windows-core 0.56.0", +] + +[[package]] +name = "tree-sitter" +version = "0.27.0" +source = "git+https://github.com/tree-sitter/tree-sitter?rev=dff1fd868c750dbbae179fcd5c43ce987e4e0528#dff1fd868c750dbbae179fcd5c43ce987e4e0528" +dependencies = [ + "cc", + "regex", + "serde_json", + "streaming-iterator", + "tree-sitter-language", + "wasmtime-c-api-impl", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "git+https://github.com/tree-sitter/tree-sitter?rev=dff1fd868c750dbbae179fcd5c43ce987e4e0528#dff1fd868c750dbbae179fcd5c43ce987e4e0528" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -7008,6 +10299,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7020,6 +10330,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -7056,6 +10372,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "ui_input" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "component", + "gpui", + "ui", +] + [[package]] name = "ui_macros" version = "0.1.0" @@ -7095,6 +10421,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -7107,6 +10439,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -7143,6 +10484,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" @@ -7188,8 +10535,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.46.0" @@ -7217,6 +10571,18 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -7229,6 +10595,47 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "collections", + "command-fds", + "dirs", + "dunce", + "futures", + "futures-lite 1.13.0", + "globset", + "gpui_util", + "itertools 0.14.0", + "libc", + "log", + "mach2 0.5.0", + "nix 0.29.0", + "path", + "percent-encoding", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex 1.3.0", + "smol", + "take-until", + "tempfile", + "tendril 0.4.3", + "unicase", + "url", + "walkdir", + "which 6.0.3", + "windows 0.61.3", +] + [[package]] name = "util_macros" version = "0.1.0" @@ -7323,6 +10730,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vim_mode_setting" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "settings", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -7382,6 +10798,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -7452,6 +10877,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +dependencies = [ + "leb128fmt", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + [[package]] name = "wasm_thread" version = "0.3.3" @@ -7463,6 +10908,284 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasmtime" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d05c745dc0978e589ef295958f3130122afc33d96af6bad3f0f06dbe7ac43a8" +dependencies = [ + "addr2line", + "anyhow", + "bitflags 2.13.1", + "bumpalo", + "cc", + "cfg-if", + "hashbrown 0.15.5", + "indexmap", + "libc", + "log", + "mach2 0.4.3", + "memfd", + "object 0.37.3", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix 1.1.4", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.5", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-asm-macros", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-c-api-impl" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35b46d644ad4caf48ec1b45c4059b9f41a3e114ce5c0f7d3b53fef891c61d35" +dependencies = [ + "anyhow", + "log", + "tracing", + "wasmtime", + "wasmtime-internal-c-api-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd1d43cfaa1a0859d2f4fccc15e7e571e2a88b357e81bc88ba6c501b83d925d" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object 0.37.3", + "postcard", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.5", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmprinter", +] + +[[package]] +name = "wasmtime-internal-asm-macros" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515dd7158bf1719b41290cd2e6a2a46ec944484146816992f195af3720e49b3f" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-internal-c-api-macros" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb19363c969ac158cab961ff379243d09481493e35b391d0c60deed4fb32e286" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba1736927b58e50e741e407da7c037c0250f3e213833a09c89dcd8f73ae2eac" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object 0.37.3", + "pulley-interpreter", + "smallvec", + "target-lexicon 0.13.5", + "thiserror 2.0.20", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b238e4c20bddb900ec0cb380252d63e8d0644fd94de001119574f5921e895d9" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f259b13685ad51e3dcf58cb69031279ed0d79c25bc3ccc8b50e7160ed04fbfe" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fed85537936b16460bac352ad149052c025db50467c7bc539dd47b31439374" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fff10da41d0d15d90ebba70946a0aa16ed0957ae7b77e0b6d2a46e8221e555" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44a8c097bab08d349d57dce1ab818859fefbe261ab3632b38fe127b1b551108" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f40a57d5e7c221ce56391d7dca0a918ba17ea00185462c7facbf534d7745184" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object 0.37.3", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e085bfce1cb2089dbeef6e280a5d598666923d3dcd308712fe429fe43c9d19f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4916cd526e1ce294984cc5b70264cfc0ca103b41ca665b58728bde250b6b82f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.37.3", + "target-lexicon 0.13.5", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "watch" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "wax" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8cbf8125142b9b30321ac8721f54c52fbcd6659f76cf863d5e2e38c07a3d7b" +dependencies = [ + "const_format", + "itertools 0.14.0", + "nom 7.1.3", + "pori", + "regex", + "thiserror 2.0.20", + "walkdir", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -7502,9 +11225,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf 0.13.1", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", ] [[package]] @@ -7551,6 +11274,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.9", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -7735,7 +11476,7 @@ dependencies = [ "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", "once_cell", - "ordered-float", + "ordered-float 5.5.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -7780,6 +11521,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "6.0.3" @@ -7814,7 +11567,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7823,6 +11576,36 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "36.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e826c012c68403725e77adf6b904c2ea809e5d464aaf25aa6eda14559300b3df" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon 0.13.5", + "thiserror 2.0.20", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.57.0" @@ -7889,6 +11672,18 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -7949,6 +11744,17 @@ dependencies = [ "windows-threading 0.2.1", ] +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -7971,6 +11777,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.57.0" @@ -8108,6 +11925,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -8141,13 +11967,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -8187,6 +12030,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -8199,6 +12048,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -8211,12 +12066,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -8229,6 +12096,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -8241,6 +12114,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -8253,6 +12132,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -8265,6 +12150,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -8323,6 +12214,97 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "workspace" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "agent_settings", + "any_vec", + "anyhow", + "async-recursion", + "chrono", + "client", + "clock", + "collections", + "component", + "db", + "dirs", + "fs", + "futures", + "futures-lite 1.13.0", + "git", + "gpui", + "http_client", + "itertools 0.14.0", + "language", + "log", + "markdown", + "menu", + "node_runtime", + "parking_lot", + "postage", + "project", + "remote", + "schemars", + "serde", + "serde_json", + "session", + "settings", + "smallvec", + "sqlez", + "strum", + "task", + "telemetry", + "theme", + "theme_settings", + "ui", + "ui_input", + "url", + "util", + "uuid", + "zed_actions", +] + +[[package]] +name = "worktree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-lock", + "clock", + "collections", + "encoding_rs", + "fs", + "futures", + "futures-lite 1.13.0", + "fuzzy", + "git", + "gpui", + "ignore", + "language", + "log", + "parking_lot", + "paths", + "postage", + "rpc", + "settings", + "smallvec", + "sum_tree", + "text", + "tracing", + "util", + "ztracing", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" @@ -8424,6 +12406,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcb" version = "1.7.1" @@ -8432,7 +12424,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ "bitflags 2.13.1", "libc", - "quick-xml", + "quick-xml 0.41.0", "x11", ] @@ -8481,18 +12473,71 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", +] + [[package]] name = "xmlwriter" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "y4m" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yawc" +version = "0.3.3" +source = "git+https://github.com/zed-industries/yawc?rev=71a452f551cac178367eaac5d7418a09afa1f3a2#71a452f551cac178367eaac5d7418a09afa1f3a2" +dependencies = [ + "base64 0.22.1", + "bytes", + "flate2", + "futures", + "getrandom 0.2.17", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "nom 8.0.0", + "pin-project", + "rand 0.8.8", + "sha1", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "yazi" version = "0.2.1" @@ -8549,9 +12594,9 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.2", "futures-core", - "futures-lite", + "futures-lite 2.6.1", "hex", "libc", "ordered-stream", @@ -8676,7 +12721,7 @@ dependencies = [ "rand 0.8.8", "screencapturekit", "screencapturekit-sys", - "sysinfo", + "sysinfo 0.31.4", "tao-core-video-sys", "windows 0.61.3", "windows-capture", @@ -8697,12 +12742,47 @@ dependencies = [ "xim-parser", ] +[[package]] +name = "zed_actions" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "gpui", + "schemars", + "serde", + "util", +] + +[[package]] +name = "zed_credentials_provider" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "credentials_provider", + "futures", + "gpui", + "paths", + "release_channel", + "serde_json", +] + +[[package]] +name = "zed_env_vars" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "env_var", +] + [[package]] name = "zeddy" version = "0.1.0" dependencies = [ "anyhow", "assets", + "collections", + "editor", "futures", "gpui", "gpui_platform", @@ -8710,25 +12790,28 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "settings", + "task", "tempfile", + "terminal", + "terminal_view", "theme", "toml 0.9.12+spec-1.1.0", "ui", "unicode-segmentation", "ureq", "url", + "util", "wry", "zeddy-herdr", "zeddy-plugin", "zeddy-plugin-host", - "zeddy-vt", ] [[package]] name = "zeddy-herdr" version = "0.1.0" dependencies = [ - "base64 0.22.1", "serde", "serde_json", "tempfile", @@ -8753,14 +12836,6 @@ dependencies = [ "zeddy-plugin", ] -[[package]] -name = "zeddy-vt" -version = "0.1.0" -dependencies = [ - "alacritty_terminal", - "libghostty-vt", -] - [[package]] name = "zeno" version = "0.3.3" @@ -8837,6 +12912,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8845,6 +12921,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8861,6 +12938,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zeta_prompt" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed.git?rev=1ea16c1ab9dd6d36649e002dc60995634da04daf#1ea16c1ab9dd6d36649e002dc60995634da04daf" +dependencies = [ + "anyhow", + "imara-diff", + "serde", + "strum", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -8884,6 +12972,35 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "ztracing" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ad60e425..23eaf236 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ # zeddy — a simple agent multiplexer. # -# Five crates, each one a boundary rather than a bag of helpers: +# Four crates, each one a boundary rather than a bag of helpers: # # zeddy-herdr the only code that knows herdr exists -# zeddy-vt the only code that knows a VT parser exists # zeddy-plugin the contract a native plugin is written against # zeddy-plugin-host the only code that loads foreign code -# zeddy the window, and nothing a lower crate could own +# zeddy the window and the pinned Zed terminal host # # Nothing above depends on anything below it out of order, and no crate but # `zeddy` links GPUI's platform backend. @@ -17,8 +16,8 @@ members = [ "crates/zeddy-herdr", "crates/zeddy-plugin", "crates/zeddy-plugin-host", - "crates/zeddy-vt", ] +exclude = ["vendor/zed-terminal-view"] [workspace.package] version = "0.1.0" @@ -32,10 +31,10 @@ repository = "https://github.com/rengwu/chartr-zeddy" [workspace.dependencies] # --- the Zed layer ------------------------------------------------------- # -# One pinned Zed revision supplies three crates. `gpui` is the framework, -# `gpui_platform` is the AppKit/Win32/Wayland backend, and `ui` + `theme` are -# Zed's own component kit and color system — the reason zeddy looks native -# rather than looks like a rewrite of native. Only `zeddy` may name +# One pinned Zed revision supplies the framework, platform, UI/theme, and full +# terminal model/view. Keeping the terminal pair at the same revision is what +# gives Chartr Zed's emulator, rendering, input, selection, clipboard, IME, and +# mouse behavior as one unit. Only `zeddy` may name # `gpui_platform`: a plugin that linked a second window-server backend would # register a second application with the OS. gpui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", default-features = false, features = ["x11"] } @@ -51,30 +50,22 @@ gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "1ea1 ui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } theme = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } zed_assets = { package = "assets", git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } -# --- the terminal -------------------------------------------------------- -# -# Zed's fork of alacritty's VT core supplies the output parser, whose grid -# semantics already agree with the renderer above it. Ghostty supplies the -# input encoder: terminal keyboard protocols are too stateful and too broad to -# reproduce with a table of escape sequences. Both dependencies are contained -# by `zeddy-vt`; the application sees only zeddy-owned screen and input types. -alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } -# This is the same safe-binding/Ghostty/Zig pin as chartr-rs. The binding pins -# Ghostty 22d13172cde98a0a4dda05d3d6a3fcb0dd8ed018 and requires Zig 0.16.0. -libghostty-vt = { git = "https://github.com/Uzaaft/libghostty-rs", rev = "de9fd9b0fa4ab53faebd3d489f4c74fe0ec832ec", default-features = false } - +terminal = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +terminal_view = { path = "vendor/zed-terminal-view" } +editor = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +settings = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +task = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +collections = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +util = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } # --- everything else ----------------------------------------------------- anyhow = "1" -# Frames arrive on a thread of their own and must wake the window's thread -# without it polling. Already in the tree as one of GPUI's own dependencies, so -# it costs nothing new to build. +# Background tasks deliver terminal and backend events without polling the +# window thread. This is already part of GPUI's dependency graph. futures = "0.3" -# herdr's control plane is NDJSON and its frame payloads are base64. That is -# the entire wire format, so this is the entire wire dependency. +# Herdr's control plane is NDJSON. serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } -base64 = "0.22" toml = "0.9" # The whole of the native plugin loader. There is no renderer, RPC transport, # or display-list replay between a plugin's view and zeddy's element tree. @@ -85,12 +76,19 @@ wry = "0.56.1" ureq = "3" unicode-segmentation = "1" -# The dev profile is the build whose window you actually drag. GPUI and the VT -# core are both unusably slow at opt-level 0, and neither is code we are -# debugging, so they are optimised even in dev while zeddy's own crates stay -# fast to rebuild. +# The dev profile is the build whose window you actually drag. GPUI and Zed's +# terminal stack are not useful at opt-level 0, so dependencies are optimized +# in dev while Chartr's own crates stay fast to rebuild. [profile.dev.package."*"] opt-level = 2 [profile.dev] opt-level = 0 + +# Zed's crates are normally built inside its workspace and rely on these +# workspace-level patches. Keep them pinned beside the Zed revision above so +# importing `terminal_view` is reproducible outside the Zed monorepo. +[patch.crates-io] +async-process = { git = "https://github.com/zed-industries/async-process.git", rev = "0b6d6713570af61806e1e5cb40e0f757cb93fd9d" } +async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } +tree-sitter-language = { git = "https://github.com/tree-sitter/tree-sitter", rev = "dff1fd868c750dbbae179fcd5c43ce987e4e0528" } diff --git a/LICENSE-GPL b/LICENSE-GPL new file mode 100644 index 00000000..cb82534a --- /dev/null +++ b/LICENSE-GPL @@ -0,0 +1,200 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +Preamble + +The GNU General Public License is a free, copyleft license for software and other kinds of works. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. + +Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. + +Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and modification follow. + +TERMS AND CONDITIONS + +0. Definitions. +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: +a) The work must carry prominent notices stating that you modified it, and giving a relevant date. +b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: +a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors of the material; or +e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. +13. Use with the GNU Affero General Public License. +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + +Copyright (C) + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: + + Copyright (C) +This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. +This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . + +The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/README.md b/README.md index ec44f295..0cfb15db 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,12 @@ sh vendor/herdr/fetch.sh cargo run -p zeddy ``` -Building requires Zig 0.16.0 for the pinned libghostty terminal input encoder. +The workspace build requires Zig 0.16.0 for its pinned libghostty terminal +input encoder. The sidecar fetch currently builds an immutable post-0.8.2 +Herdr revision, because the latest tagged release drops non-wheel mouse input +during direct attachment. That one maintenance step requires Rustup and Zig +0.15.2; set `ZIG` when needed. The source pin can return to a release asset once +Herdr tags its semantic direct-attach mouse forwarding. The supported desktop targets are macOS and Linux under X11 or XWayland. Windows is deferred because Herdr currently uses Unix-domain sockets. Wry's @@ -61,6 +66,20 @@ backend refresh that discovers sessions updates and clears these inferred titles. Collapsed pane groups can be renamed from their context menu and otherwise use their item count as the title, such as **5 tabs**. +Every terminal is Zed's pinned `terminal` model and `TerminalView`, used as one +stack. Zed owns emulation, rendering, scrollback, resizing, keyboard +encoding, selection, clipboard, IME, links, and mouse reporting. Its local PTY +runs Herdr's native `terminal attach --takeover` client; the persistent PTY +and shell remain owned by the private Herdr daemon. Chartr owns only attachment +lifecycle, pane placement, settings/theme inputs, and platform terminal bindings. +The pinned view has one documented host extension: Chartr can top-align the grid +instead of moving it by the spare sub-row pixels during pane resize. Zed's +bottom-alignment policy remains the default inside the vendored crate. +The pinned Zed terminal keymap supplies copy/paste, word navigation, scrollback, +vi mode, and character-palette behavior; Chartr adds terminal-buffer search, +desktop file drops, filesystem-link opening, and tab bell state at the host +boundary. Terminal font changes reflow live through the shared theme provider. + ## Settings and persistence Settings uses one application-wide native window, following Zed: every chrome @@ -93,7 +112,7 @@ automatically. Normal app exit detaches sessions. An optional setting terminates them instead. The private Herdr runtime uses an exact socket under `$XDG_CONFIG_HOME/chartr-zeddy/herdr`; inherited Herdr selectors are cleared so -Chartr cannot attach to a user's standalone daemon. Broken streams become +Chartr cannot attach to a user's standalone daemon. Closed attach clients become item-local recovery states, and unexpected daemon death receives one clean restart before entering a stable crash-loop state with Retry. @@ -136,7 +155,6 @@ are development references and are not installed automatically. ```text crates/zeddy/ window, spaces, panes, settings, persistence, UI crates/zeddy-herdr/ private Herdr protocol and lifecycle -crates/zeddy-vt/ Alacritty output parser and Ghostty input encoder boundary crates/zeddy-plugin/ native and manifest authoring contract crates/zeddy-plugin-host/ discovery, loading, and web filesystem broker plugins/ one complete example per plugin tier @@ -154,8 +172,9 @@ cargo check --manifest-path plugins/hello/Cargo.toml --locked cargo test -p zeddy --test live_session -- --ignored --nocapture --test-threads=1 ``` -The last command launches and hard-crashes the real pinned private Herdr. The -macOS/Linux build matrix and release acceptance checklist live in +The last command validates native attach targets and hard-crashes the real +pinned private Herdr. Interactive terminal behavior is covered by the release +acceptance checklist. The macOS/Linux build matrix and full checklist live in `.github/workflows/ci.yml` and `docs/acceptance.md`. ## Licence diff --git a/crates/zeddy-herdr/Cargo.toml b/crates/zeddy-herdr/Cargo.toml index e3ae5c34..770f2fed 100644 --- a/crates/zeddy-herdr/Cargo.toml +++ b/crates/zeddy-herdr/Cargo.toml @@ -9,7 +9,6 @@ repository.workspace = true [dependencies] serde.workspace = true serde_json.workspace = true -base64.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/zeddy-herdr/src/control.rs b/crates/zeddy-herdr/src/control.rs index 5b8c34a0..0def510b 100644 --- a/crates/zeddy-herdr/src/control.rs +++ b/crates/zeddy-herdr/src/control.rs @@ -2,7 +2,7 @@ //! //! One request per connection, NDJSON, blocking. Blocking is deliberate — the //! calls are local, they take microseconds, and the alternative is an async -//! runtime in a crate whose entire job is six methods. +//! runtime around this deliberately small control surface. //! //! Callers on the window thread should still not sit on these directly; the app //! runs them on a background executor and delivers the answer back. This crate @@ -20,14 +20,13 @@ use std::{ use serde::{Serialize, de::DeserializeOwned}; use crate::{ - Error, Geometry, Namespace, PaneId, Result, SUPPORTED_HERDR_VERSION, SUPPORTED_PROTOCOL, - Sidecar, WorkspaceId, + Error, Namespace, PaneId, Result, SUPPORTED_HERDR_VERSION, SUPPORTED_PROTOCOL, Sidecar, + TerminalId, WorkspaceId, protocol::{ - self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, PaneReadEnvelope, - PaneReadParams, Pong, Request, Response, TabCreateParams, TabList, TabListParams, - WorkspaceCreateParams, WorkspaceList, + self, Created, Empty, PaneCloseParams, PaneList, PaneListParams, Pong, Request, Response, + ServerLiveHandoffParams, TabCreateParams, TabList, TabListParams, WorkspaceCreateParams, + WorkspaceList, }, - stream::Attachment, }; /// What an agent in a session is doing, once Herdr's vocabulary has been left @@ -59,6 +58,8 @@ impl From for SessionStatus { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { pub id: PaneId, + /// The persistent PTY identifier consumed by `herdr terminal attach`. + pub terminal: TerminalId, pub workspace: WorkspaceId, /// Herdr's persistent tab label/number, used when nothing is running. pub label: String, @@ -85,6 +86,7 @@ impl Session { .map(str::to_owned); Self { id: PaneId(pane.pane_id), + terminal: TerminalId(pane.terminal_id), workspace: WorkspaceId(pane.workspace_id), label, running, @@ -106,6 +108,18 @@ impl Session { } } +/// A complete, namespace-safe invocation of Herdr's interactive attach CLI. +/// +/// The UI layer decides how to host this command (currently in Zed's PTY), +/// while this crate remains the only layer that knows Herdr's executable, +/// arguments, or private environment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectAttach { + pub program: PathBuf, + pub args: Vec, + pub env: HashMap, +} + impl From for Session { fn from(pane: protocol::Pane) -> Self { let running = pane @@ -141,9 +155,19 @@ impl Client { /// is "can I talk to it", and that is a `ping`. pub fn connect(&self, timeout: Duration) -> Result<()> { self.namespace.prepare()?; - if self.handshake().is_ok() { - return Ok(()); + match self.handshake() { + Ok(()) => return Ok(()), + // A private daemon may outlive the Chartr build that started it + // because it owns persistent PTYs. Replace only a daemon that + // answered with an incompatible identity; an unrelated transient + // handshake failure must not trigger an upgrade. + Err(Error::IncompatibleDaemon { .. }) if self.answers() => { + self.live_handoff()?; + return self.reconnect(timeout); + } + Err(_) => {} } + self.spawn_daemon()?; let deadline = Instant::now() + timeout; @@ -160,6 +184,24 @@ impl Client { } } + /// Replace the daemon at this private socket with the pinned sidecar while + /// preserving its live PTYs. + fn live_handoff(&self) -> Result<()> { + let import_exe = self.sidecar.path().to_str().ok_or_else(|| { + Error::Sidecar(format!( + "Herdr sidecar path is not valid UTF-8: {}", + self.sidecar.path().display() + )) + })?; + let params = ServerLiveHandoffParams { + import_exe, + expected_protocol: SUPPORTED_PROTOCOL, + expected_version: SUPPORTED_HERDR_VERSION, + }; + let _: Empty = self.call("server.live_handoff", ¶ms)?; + Ok(()) + } + /// Whether anything is accepting connections at this private socket. /// /// Supervision deliberately asks the operating system rather than pinging @@ -243,19 +285,15 @@ impl Client { /// `ping`, checked against the version this client was written for. /// - /// A version mismatch is an error and not a warning. The frame stream rides + /// A version mismatch is an error and not a warning. Direct attachment rides /// herdr's command line, so a daemon zeddy did not ship is a daemon zeddy /// cannot promise to render. pub fn handshake(&self) -> Result<()> { let pong: Pong = self.call("ping", &Empty {})?; if pong.version != SUPPORTED_HERDR_VERSION || pong.protocol != SUPPORTED_PROTOCOL { - return Err(Error::Backend { - method: "ping", - message: format!( - "daemon is herdr {} (protocol {}); zeddy ships {SUPPORTED_HERDR_VERSION} \ - (protocol {SUPPORTED_PROTOCOL})", - pong.version, pong.protocol - ), + return Err(Error::IncompatibleDaemon { + version: pong.version, + protocol: pong.protocol, }); } Ok(()) @@ -372,29 +410,38 @@ impl Client { Ok(()) } - /// Styled host scrollback, oldest requested row first and including the - /// live viewport at the bottom. - pub fn history(&self, pane: &PaneId, lines: u32) -> Result { - let read: PaneReadEnvelope = self.call( - "pane.read", - &PaneReadParams { - pane_id: &pane.0, - source: "recent", - lines, - format: "ansi", - strip_ansi: false, - }, - )?; - Ok(read.read.text) - } - - /// Attach to a session's byte stream at a given geometry. + /// Complete launch specification for Herdr's native interactive terminal + /// client. /// - /// The client hands its own sidecar and namespace to the attachment, so the - /// stream cannot end up pointed at a herdr the control plane is not talking - /// to. - pub fn attach(&self, pane: &PaneId, geometry: Geometry) -> Result { - Attachment::open(&self.sidecar, &self.namespace, pane, geometry) + /// `--takeover` makes Chartr the sole controller after a relaunch instead + /// of failing because a dead or superseded frontend still owns the stream. + /// `/usr/bin/env -u` is the standard process adapter on both supported + /// desktop targets; it removes inherited Herdr selectors before executing + /// the exact vendored sidecar. The terminal host therefore consumes this + /// value without knowing or reconstructing Herdr's namespace rules. + pub fn direct_attach(&self, terminal: &TerminalId) -> DirectAttach { + let mut env = HashMap::new(); + let mut args = Vec::new(); + for (key, value) in self.namespace.env() { + let key = key.to_string_lossy().into_owned(); + match value { + Some(value) => { + env.insert(key, value.to_string_lossy().into_owned()); + } + None => { + args.push("-u".to_owned()); + args.push(key); + } + } + } + args.push(self.sidecar.path().to_string_lossy().into_owned()); + args.extend([ + "terminal".to_owned(), + "attach".to_owned(), + terminal.0.clone(), + "--takeover".to_owned(), + ]); + DirectAttach { program: PathBuf::from("/usr/bin/env"), args, env } } /// Send one request, read one response, close the connection. @@ -498,11 +545,14 @@ fn next_id() -> String { #[cfg(test)] mod tests { + use std::os::unix::net::UnixListener; + use super::*; fn pane(id: &str, title: Option<&str>, agent: Option<&str>) -> protocol::Pane { protocol::Pane { pane_id: id.to_owned(), + terminal_id: format!("term-{id}"), workspace_id: "w1".to_owned(), tab_id: "w1:t1".to_owned(), title: title.map(str::to_owned), @@ -537,6 +587,7 @@ mod tests { fn an_unknown_future_agent_status_degrades_to_unknown() { let pane: protocol::Pane = serde_json::from_value(serde_json::json!({ "pane_id": "p1", + "terminal_id": "term1", "agent_status": "meditating" })) .expect("pane"); @@ -572,6 +623,35 @@ mod tests { ); } + #[test] + fn direct_attach_uses_the_terminal_id_and_private_namespace() { + let tmp = tempfile::tempdir().expect("tempdir"); + let executable = tmp.path().join("herdr"); + std::fs::write(&executable, []).expect("sidecar fixture"); + let namespace = Namespace::rooted(tmp.path().join("private/herdr")); + let client = Client::new(Sidecar::at(&executable).expect("sidecar"), namespace.clone()); + + let attach = client.direct_attach(&TerminalId("terminal-7".to_owned())); + + assert_eq!(attach.program, PathBuf::from("/usr/bin/env")); + assert!(attach.args.windows(2).any(|pair| pair == ["-u", "HERDR_PANE_ID"])); + assert!(attach.args.windows(2).any(|pair| pair == ["-u", "HERDR_SESSION"])); + assert_eq!( + &attach.args[attach.args.len() - 5..], + &[ + executable.to_string_lossy().into_owned(), + "terminal".to_owned(), + "attach".to_owned(), + "terminal-7".to_owned(), + "--takeover".to_owned(), + ] + ); + assert_eq!( + attach.env.get("HERDR_SOCKET_PATH"), + Some(&namespace.socket().to_string_lossy().into_owned()) + ); + } + #[test] fn an_ordinary_foreground_process_is_distinct_from_an_agent() { let session = Session::from_pane( @@ -596,6 +676,49 @@ mod tests { assert!(err.to_string().contains(&namespace.socket().display().to_string()), "{err}"); } + #[test] + fn connect_live_handoffs_an_incompatible_private_daemon() { + let tmp = tempfile::tempdir().expect("tempdir"); + let namespace = Namespace::rooted(tmp.path().join("private")); + namespace.prepare().expect("prepare namespace"); + let herdr = tmp.path().join("herdr"); + std::fs::write(&herdr, b"sidecar fixture").expect("sidecar fixture"); + let listener = UnixListener::bind(namespace.socket()).expect("test daemon socket"); + let expected_exe = herdr.to_string_lossy().into_owned(); + + let server = std::thread::spawn(move || { + let (mut first_ping, _) = listener.accept().expect("first ping"); + let request = read_test_request(&mut first_ping); + assert_eq!(request["method"], "ping"); + writeln!(first_ping, r#"{{"id":"1","result":{{"version":"0.8.0","protocol":19}}}}"#) + .expect("old ping response"); + + // `answers` deliberately performs only an OS-level connect. + let (_probe, _) = listener.accept().expect("socket probe"); + + let (mut handoff, _) = listener.accept().expect("handoff request"); + let request = read_test_request(&mut handoff); + assert_eq!(request["method"], "server.live_handoff"); + assert_eq!(request["params"]["import_exe"], expected_exe); + assert_eq!(request["params"]["expected_protocol"], SUPPORTED_PROTOCOL); + assert_eq!(request["params"]["expected_version"], SUPPORTED_HERDR_VERSION); + writeln!(handoff, r#"{{"id":"2","result":{{}}}}"#).expect("handoff response"); + + let (mut final_ping, _) = listener.accept().expect("final ping"); + let request = read_test_request(&mut final_ping); + assert_eq!(request["method"], "ping"); + writeln!( + final_ping, + r#"{{"id":"3","result":{{"version":"{SUPPORTED_HERDR_VERSION}","protocol":{SUPPORTED_PROTOCOL}}}}}"# + ) + .expect("new ping response"); + }); + + let client = Client::new(Sidecar::at(&herdr).expect("sidecar"), namespace); + client.connect(Duration::from_secs(1)).expect("handoff reaches the pinned daemon"); + server.join().expect("test daemon"); + } + #[test] fn a_clean_restart_removes_only_the_saved_shape() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -616,4 +739,10 @@ mod tests { assert_eq!(std::fs::read(&neighbor).expect("config remains"), b"managed config"); client.clear_saved_shape().expect("already absent is harmless"); } + + fn read_test_request(stream: &mut UnixStream) -> serde_json::Value { + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).expect("read request"); + serde_json::from_str(&line).expect("request JSON") + } } diff --git a/crates/zeddy-herdr/src/lib.rs b/crates/zeddy-herdr/src/lib.rs index 72045449..68839a46 100644 --- a/crates/zeddy-herdr/src/lib.rs +++ b/crates/zeddy-herdr/src/lib.rs @@ -2,26 +2,17 @@ //! //! herdr is infrastructure zeddy hides rather than a feature zeddy exposes. //! Nothing above this crate knows the name, and the only thing this crate -//! promises upward is: a list of live panes, a stream of bytes per pane, and a -//! way to push bytes and geometry back down. -//! -//! # Two surfaces, two transports -//! -//! - [`control`] — the socket API. NDJSON over a Unix socket, one request per -//! connection. Creating, listing, and closing panes happens here. -//! - [`stream`] — the terminal byte stream. herdr exposes this as a CLI stream, -//! not a socket method, so attaching means spawning a child process. That is -//! a real coupling to herdr's command line and it is why -//! [`SUPPORTED_HERDR_VERSION`] is pinned rather than probed. +//! promises upward is: lifecycle and metadata over the socket API, plus a +//! namespace-safe process specification for Herdr's native interactive +//! `terminal attach` client. //! //! # Whose herdr //! //! zeddy runs a **private** daemon: its own socket, its own XDG directories, //! its own session name. A [`Namespace`] is those locations plus the -//! environment every herdr process zeddy launches is placed in, and both a -//! [`control::Client`] and a [`stream::Attachment`] carry one. An inherited -//! `HERDR_SOCKET_PATH` from the user's own shell therefore cannot split zeddy -//! across two backends. +//! environment every herdr process zeddy launches is placed in, and the +//! [`control::Client`] carries one. An inherited `HERDR_SOCKET_PATH` from the +//! user's own shell therefore cannot split zeddy across two backends. //! //! The user's own herdr is never discovered, attached to, stopped, upgraded, or //! written. @@ -33,22 +24,31 @@ pub mod control; pub mod namespace; pub mod protocol; pub mod sidecar; -pub mod stream; pub use namespace::Namespace; pub use sidecar::Sidecar; use std::fmt; -/// The exact herdr release this client speaks to. +/// The exact Herdr build this client speaks to. /// -/// Not a floor and not a range. The frame stream rides herdr's command line, +/// Not a floor and not a range. Direct attachment rides Herdr's command line, /// which carries no compatibility promise, so the client and the vendored /// executable move together or not at all. -pub const SUPPORTED_HERDR_VERSION: &str = "0.8.0"; +pub const SUPPORTED_HERDR_VERSION: &str = "0.8.2-zeddy.5a2dee700eee"; + +/// The upstream package version used to produce [`SUPPORTED_HERDR_VERSION`]. +pub const SUPPORTED_HERDR_UPSTREAM_VERSION: &str = "0.8.2"; + +/// The immutable upstream source revision used to build the sidecar. +/// +/// This post-0.8.2 revision carries Herdr's semantic direct-attach mouse +/// forwarding. Tagged 0.8.2 consumes non-wheel reports before they reach the +/// child terminal, which makes mouse-aware TUIs such as Claude Code unclickable. +pub const SUPPORTED_HERDR_REVISION: &str = "5a2dee700eeeea68267a4d16777307632f77172f"; /// The socket API protocol version [`SUPPORTED_HERDR_VERSION`] speaks. -pub const SUPPORTED_PROTOCOL: u32 = 19; +pub const SUPPORTED_PROTOCOL: u32 = 22; /// One terminal — a herdr *pane*. /// @@ -58,6 +58,10 @@ pub const SUPPORTED_PROTOCOL: u32 = 19; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PaneId(pub String); +/// The persistent PTY Herdr exposes through `terminal attach`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TerminalId(pub String); + /// A group of panes — a herdr *workspace*, one per project directory. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct WorkspaceId(pub String); @@ -68,33 +72,15 @@ impl fmt::Display for PaneId { } } -impl fmt::Display for WorkspaceId { +impl fmt::Display for TerminalId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -/// The grid a pane's PTY is running at. -/// -/// zeddy owns this, not herdr: the window decides how many cells fit and tells -/// the backend, never the other way round. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Geometry { - pub cols: u16, - pub rows: u16, -} - -impl Geometry { - /// A geometry clamped to what a PTY will accept. A zero-sized grid is a - /// real thing to compute during a resize and not a real thing to send. - pub fn new(cols: u16, rows: u16) -> Self { - Self { cols: cols.max(1), rows: rows.max(1) } - } -} - -impl Default for Geometry { - fn default() -> Self { - Self::new(80, 24) +impl fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) } } @@ -105,6 +91,8 @@ pub enum Error { Sidecar(String), /// The socket refused, closed, or was never there. Transport(std::io::Error), + /// A private daemon answered but is not the exact build this client ships. + IncompatibleDaemon { version: String, protocol: u32 }, /// herdr answered, and the answer was a failure. Backend { method: &'static str, message: String }, /// herdr answered with something this client cannot read. @@ -118,6 +106,11 @@ impl fmt::Display for Error { match self { Self::Sidecar(why) => write!(f, "herdr sidecar unusable: {why}"), Self::Transport(err) => write!(f, "herdr transport failed: {err}"), + Self::IncompatibleDaemon { version, protocol } => write!( + f, + "daemon is herdr {version} (protocol {protocol}); zeddy ships \ + {SUPPORTED_HERDR_VERSION} (protocol {SUPPORTED_PROTOCOL})" + ), Self::Backend { method, message } => write!(f, "herdr rejected {method}: {message}"), Self::Protocol(why) => write!(f, "herdr sent something unreadable: {why}"), } diff --git a/crates/zeddy-herdr/src/namespace.rs b/crates/zeddy-herdr/src/namespace.rs index 6d289423..08de2d91 100644 --- a/crates/zeddy-herdr/src/namespace.rs +++ b/crates/zeddy-herdr/src/namespace.rs @@ -3,7 +3,7 @@ //! Every path here is under a single zeddy-owned root, so "which herdr" is one //! decision made once rather than a rule each call site has to remember. The //! environment in [`Namespace::env`] is applied to every herdr process zeddy -//! launches — the daemon and each frame stream alike — which is what keeps a +//! launches — the daemon and each interactive attach client alike — which keeps a //! `HERDR_SOCKET_PATH` inherited from the user's shell from reaching herdr at //! all. diff --git a/crates/zeddy-herdr/src/protocol.rs b/crates/zeddy-herdr/src/protocol.rs index 2db6d33c..65c5296e 100644 --- a/crates/zeddy-herdr/src/protocol.rs +++ b/crates/zeddy-herdr/src/protocol.rs @@ -1,6 +1,6 @@ //! herdr's wire types — exactly the ones zeddy sends or reads, and no more. //! -//! herdr's socket API has ninety methods. zeddy uses eight of them. Modelling +//! herdr's socket API has ninety methods. zeddy uses nine of them. Modelling //! only those keeps the pin in [`crate::SUPPORTED_HERDR_VERSION`] honest: a //! herdr release can change anything zeddy does not name here without zeddy //! having an opinion about it. @@ -40,9 +40,18 @@ pub struct ErrorBody { } /// Methods take a params object even when they take no parameters. -#[derive(Debug, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Empty {} +/// `server.live_handoff` — replace an incompatible private daemon without +/// terminating the PTYs it owns. +#[derive(Debug, Serialize)] +pub struct ServerLiveHandoffParams<'a> { + pub import_exe: &'a str, + pub expected_protocol: u32, + pub expected_version: &'a str, +} + /// `ping` — the handshake. Its answer is the version check. #[derive(Debug, Deserialize)] pub struct Pong { @@ -66,6 +75,9 @@ pub struct Workspace { #[derive(Debug, Clone, Deserialize)] pub struct Pane { pub pane_id: String, + /// The server-owned PTY behind this pane. Herdr's interactive attach CLI + /// addresses the terminal rather than the pane that currently displays it. + pub terminal_id: String, #[serde(default)] pub workspace_id: String, /// The Herdr tab containing this pane. Chartr keeps one session per Herdr @@ -189,29 +201,6 @@ pub struct PaneCloseParams<'a> { pub pane_id: &'a str, } -#[derive(Debug, Serialize)] -pub struct PaneReadParams<'a> { - pub pane_id: &'a str, - pub source: &'static str, - pub lines: u32, - pub format: &'static str, - pub strip_ansi: bool, -} - -#[derive(Debug, Deserialize)] -pub struct PaneRead { - pub text: String, - #[serde(default)] - pub revision: u64, - #[serde(default)] - pub truncated: bool, -} - -#[derive(Debug, Deserialize)] -pub struct PaneReadEnvelope { - pub read: PaneRead, -} - #[derive(Debug, Deserialize)] pub struct WorkspaceList { #[serde(default)] @@ -230,55 +219,6 @@ pub struct Created { pub root_pane: Pane, } -// --- the data plane ------------------------------------------------------ - -/// A line of `herdr terminal session control`'s stdout. -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum StreamMessage { - #[serde(rename = "terminal.frame")] - Frame(RawFrame), - #[serde(rename = "terminal.closed")] - Closed(Closed), -} - -/// A repaint, as it arrives: base64 ANSI plus the geometry it was painted for. -/// -/// Only the first frame after an attach or a resize is `full`. Every other one -/// is a diff against what the frames before it drew, which is why -/// [`crate::stream::Frames`] refuses a stream with a gap in `seq` rather than -/// painting a plausible-looking wrong screen. -#[derive(Debug, Clone, Deserialize)] -pub struct RawFrame { - pub bytes: String, - #[serde(default)] - pub full: bool, - #[serde(default)] - pub seq: u64, - #[serde(default)] - pub width: u16, - #[serde(default)] - pub height: u16, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Closed { - #[serde(default)] - pub reason: String, -} - -/// A line written to `herdr terminal session control`'s stdin. -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type")] -pub enum StreamCommand { - #[serde(rename = "terminal.input")] - Input { bytes: String }, - #[serde(rename = "terminal.resize")] - Resize { cols: u16, rows: u16 }, - #[serde(rename = "terminal.release")] - Release, -} - #[cfg(test)] mod tests { use super::*; @@ -293,34 +233,8 @@ mod tests { #[test] fn unknown_response_fields_do_not_fail_the_parse() { - let raw = r#"{"id":"1","result":{"panes":[{"pane_id":"p1","invented_in_0_9":true}]}}"#; + let raw = r#"{"id":"1","result":{"panes":[{"pane_id":"p1","terminal_id":"term1","invented_in_0_9":true}]}}"#; let parsed: Response = serde_json::from_str(raw).expect("parses"); assert_eq!(parsed.result.expect("result").panes[0].pane_id, "p1"); } - - #[test] - fn styled_history_uses_the_pane_read_envelope() { - let raw = r#"{"id":"1","result":{"type":"pane_read","read":{"pane_id":"p1","workspace_id":"w1","tab_id":"t1","source":"recent","format":"ansi","text":"\u001b[31mred","revision":4,"truncated":false}}}"#; - let parsed: Response = serde_json::from_str(raw).expect("parses"); - let read = parsed.result.expect("result").read; - assert_eq!(read.text, "\x1b[31mred"); - assert_eq!(read.revision, 4); - assert!(!read.truncated); - } - - #[test] - fn stream_messages_are_tagged_by_type() { - let raw = r#"{"type":"terminal.frame","bytes":"aGk=","full":true,"seq":0,"width":80,"height":24}"#; - match serde_json::from_str::(raw).expect("parses") { - StreamMessage::Frame(frame) => assert!(frame.full && frame.seq == 0), - StreamMessage::Closed(_) => panic!("that was a frame"), - } - } - - #[test] - fn commands_serialise_the_way_herdr_reads_them() { - let json = serde_json::to_string(&StreamCommand::Resize { cols: 120, rows: 40 }) - .expect("serialises"); - assert_eq!(json, r#"{"type":"terminal.resize","cols":120,"rows":40}"#); - } } diff --git a/crates/zeddy-herdr/src/sidecar.rs b/crates/zeddy-herdr/src/sidecar.rs index add06dbe..f9b89191 100644 --- a/crates/zeddy-herdr/src/sidecar.rs +++ b/crates/zeddy-herdr/src/sidecar.rs @@ -1,7 +1,7 @@ //! The herdr executable zeddy ships, resolved by path and never through `PATH`. //! -//! Both halves of the client — the socket and the frame stream — spawn or -//! handshake herdr, and they must be the same build. Resolving once and +//! Both halves of the integration — control requests and interactive attach — +//! execute or handshake Herdr, and they must be the same build. Resolving once and //! carrying the result makes that true by construction instead of by //! convention. diff --git a/crates/zeddy-herdr/src/stream.rs b/crates/zeddy-herdr/src/stream.rs deleted file mode 100644 index 98786204..00000000 --- a/crates/zeddy-herdr/src/stream.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! The data plane: one session's stream of screen repaints. -//! -//! herdr exposes this as a CLI stream rather than a socket method, so attaching -//! means spawning `herdr terminal session control ` and speaking NDJSON -//! over its stdio. -//! -//! # Frames are diffs -//! -//! Only the first frame after an attach or a resize repaints the whole screen. -//! Every frame after it is a delta that assumes its predecessors were applied, -//! so a consumer must feed **every** frame, in order, to **one** emulator, and -//! must never skip one to catch up. [`Frames`] enforces exactly that: it -//! refuses a stream that does not start with a full repaint, and refuses a gap, -//! a repeat, or a rewind in the sequence. A broken stream surfaces as an error -//! rather than as a screen that looks plausible and is wrong. -//! -//! # Reading and writing are two halves -//! -//! [`Frames::next_frame`] blocks until herdr has something to say, which for an idle -//! session is "never". Anything that also has to deliver a keystroke the -//! instant it is typed cannot hold both ends on one thread, so [`Attachment`] -//! splits: the reader goes to a thread of its own, the [`Input`] half stays -//! with whatever is driving the session. The child outlives whichever half is -//! dropped first, and dies when both are gone. - -use std::{ - io::{BufRead, BufReader, Write}, - process::{Child, ChildStdin, ChildStdout, Command, Stdio}, - sync::{Arc, Mutex}, -}; - -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; - -use crate::{ - Error, Geometry, Namespace, PaneId, Result, Sidecar, control, - protocol::{RawFrame, StreamCommand, StreamMessage}, -}; - -/// One repaint, decoded and ready to feed to a VT parser. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Frame { - /// ANSI bytes. Feed them to the emulator verbatim. - pub bytes: Vec, - /// Whether this frame repaints the whole screen rather than a region of it. - pub full: bool, - /// herdr's monotonic counter. - pub seq: u64, - /// The geometry this frame was painted for. After a resize, the first frame - /// carrying the new geometry is also the one that repaints in full. - pub geometry: Geometry, -} - -/// The child process, shared by both halves so that dropping one does not end -/// the attachment the other is still using. -#[derive(Debug)] -struct Process(Mutex); - -impl Drop for Process { - fn drop(&mut self) { - if let Ok(mut child) = self.0.lock() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -/// A live attachment to one session. -#[derive(Debug)] -pub struct Attachment { - frames: Frames, - input: Input, -} - -impl Attachment { - /// Attach to `pane` at `geometry`. - /// - /// The geometry is passed at attach time rather than sent afterwards so - /// that the very first full repaint is already the right size — a terminal - /// that opens at 80×24 and corrects itself a frame later is a visible flash. - pub fn open( - sidecar: &Sidecar, - namespace: &Namespace, - pane: &PaneId, - geometry: Geometry, - ) -> Result { - let mut command = Command::new(sidecar.path()); - command - .args(["terminal", "session", "control", &pane.0]) - .args(["--cols".to_owned(), geometry.cols.to_string()]) - .args(["--rows".to_owned(), geometry.rows.to_string()]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - control::apply(&mut command, namespace); - - let mut child = command.spawn()?; - let stdout = child - .stdout - .take() - .ok_or_else(|| Error::Protocol("herdr's frame stream has no stdout".to_owned()))?; - let stdin = child - .stdin - .take() - .ok_or_else(|| Error::Protocol("herdr's frame stream has no stdin".to_owned()))?; - - let process = Arc::new(Process(Mutex::new(child))); - Ok(Self { - frames: Frames { - reader: BufReader::new(stdout), - sequence: Sequence::default(), - _process: process.clone(), - }, - input: Input { stdin, _process: process }, - }) - } - - /// Hand the two halves out, so the reader can go to its own thread. - pub fn split(self) -> (Frames, Input) { - (self.frames, self.input) - } -} - -/// The frame contract, as a state machine of its own. -/// -/// Split out from [`Frames`] so the rule can be tested without a child process -/// — and so there is exactly one place that decides whether a frame is safe to -/// paint. -#[derive(Debug, Default)] -struct Sequence { - /// The `seq` the next frame must carry. `None` before the first one, which - /// is also the one that must be a full repaint. - expected: Option, -} - -impl Sequence { - /// Check a frame against the contract, and decode it if it holds. - fn accept(&mut self, raw: RawFrame) -> Result { - match self.expected { - None if !raw.full => { - return Err(Error::Protocol(format!( - "the stream opened with a diff (seq {}) instead of a full repaint", - raw.seq - ))); - } - Some(expected) if raw.seq != expected => { - return Err(Error::Protocol(format!( - "frame {} arrived where {expected} was due; the screen would be wrong", - raw.seq - ))); - } - _ => {} - } - self.expected = Some(raw.seq + 1); - - let bytes = BASE64 - .decode(raw.bytes.as_bytes()) - .map_err(|err| Error::Protocol(format!("frame {} is not base64: {err}", raw.seq)))?; - Ok(Frame { - bytes, - full: raw.full, - seq: raw.seq, - geometry: Geometry::new(raw.width, raw.height), - }) - } -} - -/// The reading half: repaints, in order, or an error. -#[derive(Debug)] -pub struct Frames { - reader: BufReader, - sequence: Sequence, - _process: Arc, -} - -impl Frames { - /// Block until the next repaint arrives. - /// - /// `Ok(None)` means herdr closed the stream cleanly — the session ended, or - /// something else took it over. That is an outcome, not a failure. - pub fn next_frame(&mut self) -> Result> { - loop { - let mut line = String::new(); - if self.reader.read_line(&mut line)? == 0 { - return Ok(None); - } - if line.trim().is_empty() { - continue; - } - let message: StreamMessage = serde_json::from_str(&line) - .map_err(|err| Error::Protocol(format!("unreadable frame: {err}")))?; - return match message { - StreamMessage::Closed(_) => Ok(None), - StreamMessage::Frame(raw) => self.sequence.accept(raw).map(Some), - }; - } - } -} - -/// The writing half: keystrokes and geometry. -#[derive(Debug)] -pub struct Input { - stdin: ChildStdin, - _process: Arc, -} - -impl Input { - /// Write raw bytes to the session's PTY. - pub fn send(&mut self, bytes: &[u8]) -> Result<()> { - self.write(&StreamCommand::Input { bytes: BASE64.encode(bytes) }) - } - - /// Resize the PTY, which delivers `SIGWINCH` to whatever is running in it. - /// - /// The next frame after this will be a full repaint at the new size. - pub fn resize(&mut self, geometry: Geometry) -> Result<()> { - self.write(&StreamCommand::Resize { cols: geometry.cols, rows: geometry.rows }) - } - - /// Detach cleanly, leaving the session running for the next attach. - pub fn release(&mut self) -> Result<()> { - self.write(&StreamCommand::Release) - } - - fn write(&mut self, command: &StreamCommand) -> Result<()> { - let mut line = serde_json::to_vec(command) - .map_err(|err| Error::Protocol(format!("cannot encode a stream command: {err}")))?; - line.push(b'\n'); - self.stdin.write_all(&line)?; - self.stdin.flush()?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - //! The frame contract is the part worth testing without a backend. - //! Attaching for real needs a live daemon and lives in the ignored smoke - //! tests. - - use super::*; - - fn raw(seq: u64, full: bool) -> RawFrame { - RawFrame { bytes: BASE64.encode(b"hi"), full, seq, width: 80, height: 24 } - } - - #[test] - fn a_stream_must_open_with_a_full_repaint() { - let err = Sequence::default().accept(raw(0, false)).expect_err("a diff cannot be first"); - assert!(err.to_string().contains("full repaint"), "{err}"); - } - - #[test] - fn frames_in_order_are_accepted_and_decoded() { - let mut sequence = Sequence::default(); - let first = sequence.accept(raw(0, true)).expect("first frame"); - assert_eq!(first.bytes, b"hi"); - assert_eq!(first.geometry, Geometry::new(80, 24)); - sequence.accept(raw(1, false)).expect("second frame"); - sequence.accept(raw(2, false)).expect("third frame"); - } - - #[test] - fn a_gap_is_an_error_and_not_a_wrong_screen() { - let mut sequence = Sequence::default(); - sequence.accept(raw(0, true)).expect("first frame"); - let err = sequence.accept(raw(2, false)).expect_err("frame 1 never arrived"); - assert!(err.to_string().contains("frame 2 arrived where 1 was due"), "{err}"); - } - - #[test] - fn a_repeat_is_rejected_too() { - let mut sequence = Sequence::default(); - sequence.accept(raw(0, true)).expect("first frame"); - sequence.accept(raw(1, false)).expect("second frame"); - assert!(sequence.accept(raw(1, false)).is_err()); - } - - #[test] - fn a_stream_may_resume_from_any_sequence_number() { - // A re-attach does not restart herdr's counter, so the contract is - // "starts full, then contiguous" and not "starts at zero". - let mut sequence = Sequence::default(); - sequence.accept(raw(9_001, true)).expect("re-attach repaints in full"); - sequence.accept(raw(9_002, false)).expect("and continues from there"); - } - - #[test] - fn a_frame_that_is_not_base64_names_itself() { - let mut bad = raw(0, true); - bad.bytes = "not base64!!".to_owned(); - let err = Sequence::default().accept(bad).expect_err("undecodable"); - assert!(err.to_string().contains("frame 0 is not base64"), "{err}"); - } -} diff --git a/crates/zeddy-vt/Cargo.toml b/crates/zeddy-vt/Cargo.toml deleted file mode 100644 index 2ea0b23f..00000000 --- a/crates/zeddy-vt/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "zeddy-vt" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -alacritty_terminal.workspace = true -libghostty-vt.workspace = true diff --git a/crates/zeddy-vt/src/lib.rs b/crates/zeddy-vt/src/lib.rs deleted file mode 100644 index c736cb95..00000000 --- a/crates/zeddy-vt/src/lib.rs +++ /dev/null @@ -1,1035 +0,0 @@ -//! zeddy's only VT boundary. -//! -//! Output bytes go in and a [`Screen`] comes out; normalized [`KeyEvent`]s go -//! in and terminal input bytes come out. This is the whole reason the crate -//! exists: neither the renderer nor the keyboard adapter above it sees an -//! escape sequence or an upstream terminal type. -//! -//! # Two deliberately different cores -//! -//! Alacritty parses output because it is the parser Zed's own terminal uses and -//! its grid semantics already agree with Zeddy's renderer. libghostty encodes -//! input because it implements the legacy, xterm, fixterms, and Kitty keyboard -//! protocols as one mode-aware encoder. Both are private implementation -//! details of this boundary. -//! -//! # Snapshots, not references -//! -//! [`Terminal::screen`] copies. A borrowed grid would be faster and would tie -//! the render pass to the lifetime of the emulator, which is owned by a -//! different thread than the one painting. At the sizes a terminal actually -//! runs — a few thousand cells — the copy is not what makes a frame slow, and -//! the freedom is worth more than the memcpy. - -#![forbid(unsafe_code)] - -use std::sync::{Arc, Mutex}; - -use alacritty_terminal::{ - event::{Event, EventListener}, - grid::{Dimensions, Scroll as AlacrittyScroll}, - index::{Column, Line, Point}, - term::{Config, TermMode, cell::Flags}, - vte::ansi::{Color as AnsiColor, NamedColor, Processor}, -}; -use libghostty_vt::key; - -/// An error produced while turning a normalized key event into terminal bytes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KeyEncodingError(String); - -impl std::fmt::Display for KeyEncodingError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.0) - } -} - -impl std::error::Error for KeyEncodingError {} - -impl From for KeyEncodingError { - fn from(error: libghostty_vt::Error) -> Self { - Self(error.to_string()) - } -} - -/// Declares the normalized keyboard and its Ghostty counterpart together, so -/// adding a key cannot leave either the physical identity or its unshifted -/// character behind. -macro_rules! key_codes { - ($($name:ident => $upstream:ident, $unshifted:expr;)*) => { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - #[allow(missing_docs)] - pub enum KeyCode { - $($name,)* - } - - impl KeyCode { - const ALL: &'static [Self] = &[$(Self::$name,)*]; - - /// Find the physical key which types `character` without modifiers. - pub fn typing(character: char) -> Option { - Self::ALL.iter().copied().find(|key| key.unshifted() == Some(character)) - } - - /// The character this key types without modifiers, when it has one. - pub fn unshifted(self) -> Option { - match self { - $(Self::$name => $unshifted,)* - } - } - - fn upstream(self) -> key::Key { - match self { - $(Self::$name => key::Key::$upstream,)* - } - } - } - }; -} - -key_codes! { - A => A, Some('a'); B => B, Some('b'); C => C, Some('c'); D => D, Some('d'); - E => E, Some('e'); F => F, Some('f'); G => G, Some('g'); H => H, Some('h'); - I => I, Some('i'); J => J, Some('j'); K => K, Some('k'); L => L, Some('l'); - M => M, Some('m'); N => N, Some('n'); O => O, Some('o'); P => P, Some('p'); - Q => Q, Some('q'); R => R, Some('r'); S => S, Some('s'); T => T, Some('t'); - U => U, Some('u'); V => V, Some('v'); W => W, Some('w'); X => X, Some('x'); - Y => Y, Some('y'); Z => Z, Some('z'); - - Digit0 => Digit0, Some('0'); Digit1 => Digit1, Some('1'); - Digit2 => Digit2, Some('2'); Digit3 => Digit3, Some('3'); - Digit4 => Digit4, Some('4'); Digit5 => Digit5, Some('5'); - Digit6 => Digit6, Some('6'); Digit7 => Digit7, Some('7'); - Digit8 => Digit8, Some('8'); Digit9 => Digit9, Some('9'); - - Backquote => Backquote, Some('`'); Backslash => Backslash, Some('\\'); - BracketLeft => BracketLeft, Some('['); BracketRight => BracketRight, Some(']'); - Comma => Comma, Some(','); Equal => Equal, Some('='); Minus => Minus, Some('-'); - Period => Period, Some('.'); Quote => Quote, Some('\''); Semicolon => Semicolon, Some(';'); - Slash => Slash, Some('/'); Space => Space, Some(' '); - - Enter => Enter, None; Tab => Tab, None; Escape => Escape, None; - Backspace => Backspace, None; Delete => Delete, None; Insert => Insert, None; - Home => Home, None; End => End, None; PageUp => PageUp, None; PageDown => PageDown, None; - ArrowUp => ArrowUp, None; ArrowDown => ArrowDown, None; - ArrowLeft => ArrowLeft, None; ArrowRight => ArrowRight, None; - - F1 => F1, None; F2 => F2, None; F3 => F3, None; F4 => F4, None; - F5 => F5, None; F6 => F6, None; F7 => F7, None; F8 => F8, None; - F9 => F9, None; F10 => F10, None; F11 => F11, None; F12 => F12, None; - F13 => F13, None; F14 => F14, None; F15 => F15, None; F16 => F16, None; - F17 => F17, None; F18 => F18, None; F19 => F19, None; F20 => F20, None; - F21 => F21, None; F22 => F22, None; F23 => F23, None; F24 => F24, None; - F25 => F25, None; - - BrowserBack => BrowserBack, None; BrowserForward => BrowserForward, None; - Copy => Copy, None; Cut => Cut, None; Paste => Paste, None; - Unidentified => Unidentified, None; -} - -/// One keyboard event after the window system's spelling has been normalized. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KeyEvent { - pub code: KeyCode, - /// Text after Shift/layout processing but before Control/Alt transformations. - pub text: Option, - pub action: KeyAction, - pub modifiers: Modifiers, - pub consumed_modifiers: Modifiers, - pub composing: bool, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum KeyAction { - #[default] - Press, - Repeat, - Release, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Modifiers { - pub shift: bool, - pub alt: bool, - pub control: bool, - pub super_key: bool, -} - -/// A cell under a pointer, relative to the visible terminal grid. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct CellPosition { - pub col: u16, - pub row: u16, -} - -impl CellPosition { - pub fn new(col: u16, row: u16) -> Self { - Self { col, row } - } -} - -/// One quantized vertical wheel movement over a terminal cell. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct WheelEvent { - pub lines: i32, - pub position: CellPosition, - pub modifiers: Modifiers, -} - -/// Application-wheel behavior to use when a repaint stream omitted VT modes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum WheelFallback { - /// Leave the gesture available to host scrollback. - #[default] - Scrollback, - /// Encode the gesture as an xterm SGR mouse report. - SgrMouse, -} - -/// The terminal modes which affect keyboard encoding. -/// -/// This copyable snapshot is the seam between Zeddy's background-owned output -/// parser and the window-thread-only Ghostty encoder. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct KeyboardModes { - cursor_key_application: bool, - keypad_key_application: bool, - disambiguate_escape_codes: bool, - report_event_types: bool, - report_alternate_keys: bool, - report_all_keys: bool, - report_associated_text: bool, -} - -/// Ghostty's keyboard encoder, kept separate because the safe binding is not -/// `Send` and must remain on the window thread which created it. -#[derive(Debug)] -pub struct KeyEncoder(key::Encoder<'static>); - -impl KeyEncoder { - pub fn new() -> Result { - Ok(Self(key::Encoder::new()?)) - } - - /// Encode one normalized event under an active terminal's mode snapshot. - pub fn encode( - &mut self, - input: &KeyEvent, - modes: KeyboardModes, - ) -> Result, KeyEncodingError> { - self.0 - .set_cursor_key_application(modes.cursor_key_application) - .set_keypad_key_application(modes.keypad_key_application) - .set_alt_esc_prefix(true) - .set_modify_other_keys_state_2(false) - .set_kitty_flags(kitty_flags(modes)) - .set_macos_option_as_alt(key::OptionAsAlt::True) - .set_backarrow_key_mode(false); - - let mut event = key::Event::new()?; - event - .set_action(match input.action { - KeyAction::Press => key::Action::Press, - KeyAction::Repeat => key::Action::Repeat, - KeyAction::Release => key::Action::Release, - }) - .set_key(input.code.upstream()) - .set_mods(key_modifiers(input.modifiers)) - .set_consumed_mods(key_modifiers(input.consumed_modifiers)) - .set_composing(input.composing); - if let Some(text) = &input.text { - event.set_utf8(Some(text.clone())); - } - if let Some(codepoint) = input - .code - .unshifted() - .or_else(|| input.text.as_ref().and_then(|text| text.chars().next())) - { - event.set_unshifted_codepoint(codepoint); - } - - let mut encoded = Vec::new(); - self.0.encode_to_vec(&event, &mut encoded)?; - Ok(encoded) - } -} - -/// A terminal grid, in cells. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Size { - pub cols: u16, - pub rows: u16, -} - -impl Size { - pub fn new(cols: u16, rows: u16) -> Self { - Self { cols: cols.max(1), rows: rows.max(1) } - } -} - -impl Default for Size { - fn default() -> Self { - Self::new(80, 24) - } -} - -/// `alacritty_terminal` asks for dimensions through a trait, so [`Size`] answers. -impl Dimensions for Size { - fn total_lines(&self) -> usize { - self.rows as usize - } - - fn screen_lines(&self) -> usize { - self.rows as usize - } - - fn columns(&self) -> usize { - self.cols as usize - } -} - -/// A cell's colour, in the terms the theme resolves rather than in RGB. -/// -/// `Default` is deliberately not "black": which colour the default foreground -/// is belongs to the theme, and resolving it here would hard-code one. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Color { - /// The theme's default foreground or background for this position. - Default, - /// One of the sixteen ANSI colours, or the 256-colour cube. - Indexed(u8), - /// A true-colour value the program asked for exactly. - Rgb(u8, u8, u8), -} - -/// How a cell is drawn, beyond its colours. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Style { - pub bold: bool, - pub italic: bool, - pub underline: bool, - pub dim: bool, - /// Foreground and background swap. Resolved by the renderer, because only - /// it knows what [`Color::Default`] actually is. - pub inverse: bool, -} - -/// One cell. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Cell { - pub ch: char, - pub fg: Color, - pub bg: Color, - pub style: Style, -} - -impl Default for Cell { - fn default() -> Self { - Self { ch: ' ', fg: Color::Default, bg: Color::Default, style: Style::default() } - } -} - -/// Where the cursor is, when it is visible. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Cursor { - pub col: u16, - pub row: u16, -} - -/// A whole screen, ready to paint, owing nothing to the emulator that made it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Screen { - pub size: Size, - /// `size.rows` rows of `size.cols` cells, top row first. - pub rows: Vec>, - pub cursor: Option, - /// The window title the program last set, if it set one. - pub title: Option, -} - -impl Screen { - /// The screen as plain text, one line per row, trailing blanks trimmed. - /// - /// Not a rendering path — this is what tests assert against and what a - /// plugin reading a session gets. - pub fn to_text(&self) -> String { - self.rows - .iter() - .map(|row| row.iter().map(|cell| cell.ch).collect::().trim_end().to_owned()) - .collect::>() - .join("\n") - } -} - -/// The emulator reports the window title as an *event*, not as grid state, so -/// something has to be listening for one to be readable at all. -/// -/// Everything else the emulator emits — clipboard requests, colour queries, -/// PTY writebacks — is a reply zeddy does not owe: herdr owns the PTY, and a -/// reply written here would never reach it. They are dropped deliberately. -#[derive(Debug, Clone, Default)] -struct TitleSink(Arc>>); - -impl EventListener for TitleSink { - fn send_event(&self, event: Event) { - match event { - Event::Title(title) => *self.0.lock().expect("title mutex") = Some(title), - Event::ResetTitle => *self.0.lock().expect("title mutex") = None, - _ => {} - } - } -} - -struct Emulation { - term: alacritty_terminal::Term, - parser: Processor, -} - -impl Emulation { - fn new(size: Size, scrolling_history: usize, title: TitleSink) -> Self { - // This permits applications to negotiate Kitty keyboard modes. It does - // not enable any flag by itself; legacy encoding remains the default. - let config = Config { scrolling_history, kitty_keyboard: true, ..Config::default() }; - Self { term: alacritty_terminal::Term::new(config, &size, title), parser: Processor::new() } - } -} - -/// The outcome of trying to move the visible viewport. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollResult { - Changed, - NeedsHistory, - Unchanged, -} - -/// A terminal emulator fed by [`Terminal::feed`]. -pub struct Terminal { - live: Emulation, - history: Option, - history_stale: bool, - generation: u64, - size: Size, - title: TitleSink, -} - -impl Terminal { - pub fn new(size: Size) -> Self { - let title = TitleSink::default(); - Self { - // Repaint frames describe only the live viewport. Letting them - // manufacture local history retains arbitrary repaint artifacts, - // so real history is loaded separately from Herdr's control plane. - live: Emulation::new(size, 0, title.clone()), - history: None, - history_stale: false, - generation: 0, - size, - title, - } - } - - pub fn size(&self) -> Size { - self.size - } - - /// Apply a repaint. Bytes must arrive in the order they were produced. - pub fn feed(&mut self, bytes: &[u8]) { - self.live.parser.advance(&mut self.live.term, bytes); - self.generation = self.generation.wrapping_add(1); - self.history_stale = self.history.is_some(); - } - - /// Re-run the grid at a new size. - /// - /// Whatever is driving this should expect a full repaint next: a resize - /// invalidates the diffs the previous frames were measured against. - pub fn resize(&mut self, size: Size) { - if size == self.size { - return; - } - self.size = size; - self.live.term.resize(size); - self.history = None; - self.history_stale = false; - self.generation = self.generation.wrapping_add(1); - } - - /// Move through the most recently loaded host scrollback. - pub fn scroll(&mut self, lines: i32) -> ScrollResult { - if lines == 0 { - return ScrollResult::Unchanged; - } - let Some(history) = self.history.as_mut() else { - return if lines > 0 { ScrollResult::NeedsHistory } else { ScrollResult::Unchanged }; - }; - if lines > 0 && self.history_stale && history.term.grid().display_offset() == 0 { - return ScrollResult::NeedsHistory; - } - - let before = history.term.grid().display_offset(); - history.term.scroll_display(AlacrittyScroll::Delta(lines)); - if before != history.term.grid().display_offset() { - ScrollResult::Changed - } else { - ScrollResult::Unchanged - } - } - - /// A token for deciding whether live output arrived during an asynchronous - /// history request. - pub fn generation(&self) -> u64 { - self.generation - } - - /// Take a copyable snapshot of the modes which affect keyboard encoding. - pub fn keyboard_modes(&self) -> KeyboardModes { - let mode = self.live.term.mode(); - KeyboardModes { - cursor_key_application: mode.contains(TermMode::APP_CURSOR), - keypad_key_application: mode.contains(TermMode::APP_KEYPAD), - disambiguate_escape_codes: mode.contains(TermMode::DISAMBIGUATE_ESC_CODES), - report_event_types: mode.contains(TermMode::REPORT_EVENT_TYPES), - report_alternate_keys: mode.contains(TermMode::REPORT_ALTERNATE_KEYS), - report_all_keys: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), - report_associated_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), - } - } - - /// Encode a wheel gesture when the application owns scrolling. - /// - /// Mouse tracking takes precedence. Otherwise xterm alternate-scroll mode - /// turns vertical wheel movement into application-cursor keys while the - /// alternate screen is active. Shift deliberately bypasses both so the - /// host terminal can expose its own scrollback. - pub fn wheel_input(&self, event: WheelEvent) -> Option> { - self.wheel_input_with_fallback(event, WheelFallback::Scrollback) - } - - /// Encode a wheel gesture, with a narrow fallback for mode-less repaint - /// streams whose control plane identifies a mouse-aware application. - pub fn wheel_input_with_fallback( - &self, - event: WheelEvent, - fallback: WheelFallback, - ) -> Option> { - if event.lines == 0 || event.modifiers.shift { - return None; - } - - let mode = self.live.term.mode(); - if mode.intersects(TermMode::MOUSE_MODE) { - // A legacy mouse encoding cannot represent every large-grid cell. - // An empty payload still means the application owns the gesture; - // it must not unexpectedly turn into host scrollback at an edge. - Some(mouse_wheel_input(event, *mode).unwrap_or_default()) - } else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) { - Some(alternate_scroll_input(event.lines)) - } else { - match fallback { - WheelFallback::Scrollback => None, - WheelFallback::SgrMouse => { - Some(mouse_wheel_input(event, TermMode::SGR_MOUSE).unwrap_or_default()) - } - } - } - } - - /// Replace the historical snapshot with ANSI-styled rows from Herdr, then - /// apply the wheel movement that requested them. - pub fn load_history(&mut self, ansi: &str, lines: i32, requested_at: u64) -> bool { - let mut history = - Emulation::new(self.size, Config::default().scrolling_history, TitleSink::default()); - let ansi = crlf(ansi); - history.parser.advance(&mut history.term, &ansi); - - // This snapshot may have become stale while it was in flight, but it - // is still the answer to the gesture that requested it. Apply that - // gesture once; `scroll` will require a refresh after returning to the - // live viewport. - let before = history.term.grid().display_offset(); - history.term.scroll_display(AlacrittyScroll::Delta(lines)); - let changed = before != history.term.grid().display_offset(); - self.history = Some(history); - self.history_stale = self.generation != requested_at; - changed - } - - /// Copy the current screen out. - pub fn screen(&self) -> Screen { - let term = self - .history - .as_ref() - .filter(|history| history.term.grid().display_offset() > 0) - .map(|history| &history.term) - .unwrap_or(&self.live.term); - screen(term, self.size, &self.title) - } -} - -fn key_modifiers(modifiers: Modifiers) -> key::Mods { - let mut result = key::Mods::empty(); - result.set(key::Mods::SHIFT, modifiers.shift); - result.set(key::Mods::ALT, modifiers.alt); - result.set(key::Mods::CTRL, modifiers.control); - result.set(key::Mods::SUPER, modifiers.super_key); - result -} - -fn kitty_flags(modes: KeyboardModes) -> key::KittyKeyFlags { - let mut flags = key::KittyKeyFlags::DISABLED; - flags.set(key::KittyKeyFlags::DISAMBIGUATE, modes.disambiguate_escape_codes); - flags.set(key::KittyKeyFlags::REPORT_EVENTS, modes.report_event_types); - flags.set(key::KittyKeyFlags::REPORT_ALTERNATES, modes.report_alternate_keys); - flags.set(key::KittyKeyFlags::REPORT_ALL, modes.report_all_keys); - flags.set(key::KittyKeyFlags::REPORT_ASSOCIATED, modes.report_associated_text); - flags -} - -fn alternate_scroll_input(lines: i32) -> Vec { - let command = if lines > 0 { b'A' } else { b'B' }; - let mut bytes = Vec::with_capacity(lines.unsigned_abs() as usize * 3); - for _ in 0..lines.unsigned_abs() { - bytes.extend_from_slice(&[b'\x1b', b'O', command]); - } - bytes -} - -fn mouse_wheel_input(event: WheelEvent, mode: TermMode) -> Option> { - let button = if event.lines > 0 { 64 } else { 65 }; - let button = button - + u8::from(event.modifiers.shift) * 4 - + u8::from(event.modifiers.alt) * 8 - + u8::from(event.modifiers.control) * 16; - - let report = if mode.contains(TermMode::SGR_MOUSE) { - format!( - "\x1b[<{button};{};{}M", - u32::from(event.position.col) + 1, - u32::from(event.position.row) + 1, - ) - .into_bytes() - } else { - normal_mouse_report(event.position, button, mode.contains(TermMode::UTF8_MOUSE))? - }; - - Some(report.repeat(event.lines.unsigned_abs() as usize)) -} - -fn normal_mouse_report(position: CellPosition, button: u8, utf8: bool) -> Option> { - let max_position = if utf8 { 2015 } else { 223 }; - if position.col >= max_position || position.row >= max_position { - return None; - } - - let mut report = vec![b'\x1b', b'[', b'M', 32 + button]; - encode_mouse_position(&mut report, position.col, utf8); - encode_mouse_position(&mut report, position.row, utf8); - Some(report) -} - -fn encode_mouse_position(report: &mut Vec, position: u16, utf8: bool) { - let position = usize::from(position) + 33; - if utf8 && position >= 128 { - report.push((0xc0 + position / 64) as u8); - report.push((0x80 + (position & 63)) as u8); - } else { - report.push(position as u8); - } -} - -fn screen(term: &alacritty_terminal::Term, size: Size, title: &TitleSink) -> Screen { - let grid = term.grid(); - let mode = term.mode(); - let display_offset = i32::try_from(grid.display_offset()).unwrap_or(i32::MAX); - let mut rows = Vec::with_capacity(size.rows as usize); - for line in 0..size.rows as i32 { - let mut cells = Vec::with_capacity(size.cols as usize); - for column in 0..size.cols as usize { - let line = Line(line.saturating_sub(display_offset)); - cells.push(convert(&grid[Point::new(line, Column(column))])); - } - rows.push(cells); - } - - let cursor = { - let point = grid.cursor.point; - let visible = mode.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); - let viewport_row = point.line.0.saturating_add(display_offset); - (visible && viewport_row >= 0 && viewport_row < i32::from(size.rows)) - .then_some(Cursor { col: point.column.0 as u16, row: viewport_row as u16 }) - }; - - Screen { size, rows, cursor, title: title.0.lock().expect("title mutex").clone() } -} - -fn crlf(text: &str) -> Vec { - let mut bytes = Vec::with_capacity(text.len()); - let mut previous = None; - for byte in text.bytes() { - if byte == b'\n' && previous != Some(b'\r') { - bytes.push(b'\r'); - } - bytes.push(byte); - previous = Some(byte); - } - bytes -} - -impl std::fmt::Debug for Terminal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Terminal").field("size", &self.size).finish_non_exhaustive() - } -} - -fn convert(cell: &alacritty_terminal::term::cell::Cell) -> Cell { - let flags = cell.flags; - Cell { - ch: cell.c, - fg: color(cell.fg), - bg: color(cell.bg), - style: Style { - bold: flags.contains(Flags::BOLD), - italic: flags.contains(Flags::ITALIC), - underline: flags.intersects(Flags::ALL_UNDERLINES), - dim: flags.contains(Flags::DIM), - inverse: flags.contains(Flags::INVERSE), - }, - } -} - -fn color(color: AnsiColor) -> Color { - match color { - AnsiColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b), - AnsiColor::Indexed(index) => Color::Indexed(index), - // The named slots that mean "whatever the theme says" stay `Default`; - // the sixteen real ANSI names become their indices, which is what a - // palette is indexed by anyway. - AnsiColor::Named( - NamedColor::Foreground - | NamedColor::Background - | NamedColor::Cursor - | NamedColor::DimForeground - | NamedColor::BrightForeground, - ) => Color::Default, - AnsiColor::Named(named) => Color::Indexed(named as u8), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn screen_of(bytes: &[u8]) -> Screen { - let mut term = Terminal::new(Size::new(20, 3)); - term.feed(bytes); - term.screen() - } - - fn press(code: KeyCode, text: Option<&str>, modifiers: Modifiers) -> KeyEvent { - KeyEvent { - code, - text: text.map(str::to_owned), - action: KeyAction::Press, - modifiers, - consumed_modifiers: Modifiers::default(), - composing: false, - } - } - - fn encoded(term: &Terminal, event: &KeyEvent) -> Vec { - KeyEncoder::new().unwrap().encode(event, term.keyboard_modes()).unwrap() - } - - #[test] - fn plain_text_lands_on_the_grid() { - assert_eq!(screen_of(b"hello").to_text().lines().next(), Some("hello")); - } - - #[test] - fn a_screen_is_always_exactly_its_size() { - let screen = screen_of(b"hi"); - assert_eq!(screen.rows.len(), 3); - assert!(screen.rows.iter().all(|row| row.len() == 20)); - } - - #[test] - fn sgr_colours_reach_the_cells() { - let screen = screen_of(b"\x1b[31mred"); - assert_eq!(screen.rows[0][0].fg, Color::Indexed(NamedColor::Red as u8)); - assert_eq!(screen.rows[0][0].bg, Color::Default, "background was never set"); - } - - #[test] - fn true_colour_survives_as_true_colour() { - let screen = screen_of(b"\x1b[38;2;10;20;30mx"); - assert_eq!(screen.rows[0][0].fg, Color::Rgb(10, 20, 30)); - } - - #[test] - fn attributes_are_carried_not_flattened_into_colour() { - let screen = screen_of(b"\x1b[1;3;4mstyled"); - let style = screen.rows[0][0].style; - assert!(style.bold && style.italic && style.underline); - } - - #[test] - fn cursor_addressing_moves_the_cursor() { - let screen = screen_of(b"\x1b[2;5H"); - assert_eq!(screen.cursor, Some(Cursor { col: 4, row: 1 })); - } - - #[test] - fn a_hidden_cursor_is_absent_rather_than_placed_somewhere() { - assert_eq!(screen_of(b"\x1b[?25l").cursor, None); - } - - #[test] - fn an_osc_title_is_picked_up() { - assert_eq!(screen_of(b"\x1b]0;a session\x07").title.as_deref(), Some("a session")); - } - - #[test] - fn feeding_a_repaint_in_two_writes_is_the_same_as_one() { - let mut split = Terminal::new(Size::new(20, 3)); - split.feed(b"\x1b[3"); - split.feed(b"1mred"); - assert_eq!(split.screen(), screen_of(b"\x1b[31mred")); - } - - #[test] - fn resizing_changes_the_shape_of_the_next_snapshot() { - let mut term = Terminal::new(Size::new(20, 3)); - term.resize(Size::new(40, 10)); - let screen = term.screen(); - assert_eq!(screen.size, Size::new(40, 10)); - assert_eq!(screen.rows.len(), 10); - assert_eq!(screen.rows[0].len(), 40); - } - - #[test] - fn host_history_can_move_the_visible_viewport() { - let mut term = Terminal::new(Size::new(20, 3)); - term.feed(b"one\r\ntwo\r\nthree\r\nfour"); - assert_eq!(term.screen().to_text(), "two\nthree\nfour"); - assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); - - let requested_at = term.generation(); - assert!(term.load_history("\x1b[31mone\x1b[0m\ntwo\nthree\nfour", 1, requested_at,)); - let history = term.screen(); - assert_eq!(history.to_text(), "one\ntwo\nthree"); - assert_eq!(history.rows[0][0].fg, Color::Indexed(NamedColor::Red as u8)); - assert_eq!(history.cursor, None, "the live cursor is outside the historical viewport"); - - assert_eq!(term.scroll(-1), ScrollResult::Changed); - assert_eq!(term.screen().to_text(), "two\nthree\nfour"); - assert_eq!(term.scroll(-1), ScrollResult::Unchanged); - } - - #[test] - fn live_output_marks_a_bottomed_history_snapshot_for_refresh() { - let mut term = Terminal::new(Size::new(20, 3)); - let requested_at = term.generation(); - assert!(term.load_history("one\ntwo\nthree\nfour", 1, requested_at)); - assert_eq!(term.scroll(-1), ScrollResult::Changed); - - term.feed(b"new output"); - assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); - } - - #[test] - fn history_loaded_after_live_output_is_already_stale() { - let mut term = Terminal::new(Size::new(20, 3)); - let requested_at = term.generation(); - - term.feed(b"latest\r\n"); - assert!(term.load_history("one\ntwo\nthree\nfour", 1, requested_at)); - assert_eq!(term.scroll(-1), ScrollResult::Changed); - assert_eq!(term.scroll(1), ScrollResult::NeedsHistory); - } - - #[test] - fn a_zero_sized_grid_is_never_handed_to_the_emulator() { - assert_eq!(Size::new(0, 0), Size::new(1, 1)); - } - - #[test] - fn ghostty_encodes_the_legacy_terminal_key_matrix() { - let term = Terminal::new(Size::default()); - let alt = Modifiers { alt: true, ..Modifiers::default() }; - let shift = Modifiers { shift: true, ..Modifiers::default() }; - let control = Modifiers { control: true, ..Modifiers::default() }; - - for (event, expected) in [ - (press(KeyCode::Enter, None, Modifiers::default()), b"\r".to_vec()), - (press(KeyCode::Enter, None, shift), b"\x1b[27;2;13~".to_vec()), - (press(KeyCode::ArrowLeft, None, alt), b"\x1b[1;3D".to_vec()), - (press(KeyCode::ArrowRight, None, control), b"\x1b[1;5C".to_vec()), - (press(KeyCode::Tab, None, shift), b"\x1b[Z".to_vec()), - (press(KeyCode::F5, None, Modifiers::default()), b"\x1b[15~".to_vec()), - (press(KeyCode::B, Some("b"), alt), b"\x1bb".to_vec()), - ( - press( - KeyCode::Slash, - Some("?"), - Modifiers { control: true, shift: true, ..Modifiers::default() }, - ), - vec![0x7f], - ), - ] { - assert_eq!( - encoded(&term, &event), - expected, - "unexpected encoding for {:?} with {:?}", - event.code, - event.modifiers, - ); - } - } - - #[test] - fn application_cursor_mode_changes_unmodified_arrows() { - let mut term = Terminal::new(Size::default()); - let left = press(KeyCode::ArrowLeft, None, Modifiers::default()); - assert_eq!(encoded(&term, &left), b"\x1b[D"); - - term.feed(b"\x1b[?1h"); - assert_eq!(encoded(&term, &left), b"\x1bOD"); - } - - #[test] - fn alternate_screen_wheel_gestures_become_application_cursor_keys() { - let mut term = Terminal::new(Size::default()); - let position = CellPosition::new(4, 2); - assert_eq!( - term.wheel_input(WheelEvent { lines: 1, position, modifiers: Modifiers::default() }), - None, - ); - - term.feed(b"\x1b[?1049h"); - assert_eq!( - term.wheel_input(WheelEvent { lines: 2, position, modifiers: Modifiers::default() }), - Some(b"\x1bOA\x1bOA".to_vec()), - ); - assert_eq!( - term.wheel_input(WheelEvent { lines: -1, position, modifiers: Modifiers::default() }), - Some(b"\x1bOB".to_vec()), - ); - } - - #[test] - fn shift_bypasses_application_wheel_input_for_host_scrollback() { - let mut term = Terminal::new(Size::default()); - term.feed(b"\x1b[?1049h\x1b[?1000h\x1b[?1006h"); - - assert_eq!( - term.wheel_input(WheelEvent { - lines: 1, - position: CellPosition::new(4, 2), - modifiers: Modifiers { shift: true, ..Modifiers::default() }, - }), - None, - ); - } - - #[test] - fn mouse_tracking_receives_sgr_wheel_reports_at_the_pointer_cell() { - let mut term = Terminal::new(Size::default()); - term.feed(b"\x1b[?1000h\x1b[?1006h"); - - assert_eq!( - term.wheel_input(WheelEvent { - lines: 2, - position: CellPosition::new(4, 2), - modifiers: Modifiers { control: true, ..Modifiers::default() }, - }), - Some(b"\x1b[<80;5;3M\x1b[<80;5;3M".to_vec()), - ); - assert_eq!( - term.wheel_input(WheelEvent { - lines: -1, - position: CellPosition::new(4, 2), - modifiers: Modifiers::default(), - }), - Some(b"\x1b[<65;5;3M".to_vec()), - ); - } - - #[test] - fn legacy_mouse_tracking_receives_wheel_reports_and_owns_unencodable_cells() { - let mut term = Terminal::new(Size::default()); - term.feed(b"\x1b[?1000h"); - - assert_eq!( - term.wheel_input(WheelEvent { - lines: 1, - position: CellPosition::new(4, 2), - modifiers: Modifiers::default(), - }), - Some(b"\x1b[M`%#".to_vec()), - ); - assert_eq!( - term.wheel_input(WheelEvent { - lines: 1, - position: CellPosition::new(300, 2), - modifiers: Modifiers::default(), - }), - Some(Vec::new()), - "mouse mode still owns positions its legacy encoding cannot represent", - ); - } - - #[test] - fn disabling_alternate_scroll_restores_host_scrollback() { - let mut term = Terminal::new(Size::default()); - term.feed(b"\x1b[?1049h\x1b[?1007l"); - - assert_eq!( - term.wheel_input(WheelEvent { - lines: 1, - position: CellPosition::default(), - modifiers: Modifiers::default(), - }), - None, - ); - } - - #[test] - fn sgr_fallback_restores_wheel_input_when_repaints_omit_modes() { - let term = Terminal::new(Size::default()); - let wheel = WheelEvent { - lines: 1, - position: CellPosition::new(4, 2), - modifiers: Modifiers::default(), - }; - - assert_eq!(term.wheel_input(wheel), None); - assert_eq!( - term.wheel_input_with_fallback(wheel, WheelFallback::SgrMouse), - Some(b"\x1b[<64;5;3M".to_vec()), - ); - } - - #[test] - fn kitty_mode_reports_modified_enter_and_key_releases() { - let mut term = Terminal::new(Size::default()); - // Disambiguate, report event types, and report every key. The latter is - // required by the Kitty protocol before Enter releases are reported. - term.feed(b"\x1b[>11u"); - let shifted_enter = - press(KeyCode::Enter, None, Modifiers { shift: true, ..Modifiers::default() }); - assert_eq!(encoded(&term, &shifted_enter), b"\x1b[13;2u"); - - let release = KeyEvent { action: KeyAction::Release, ..shifted_enter }; - assert_eq!(encoded(&term, &release), b"\x1b[13;2:3u"); - } - - #[test] - fn a_release_is_silent_until_an_application_requests_it() { - let term = Terminal::new(Size::default()); - let release = KeyEvent { - action: KeyAction::Release, - ..press(KeyCode::A, None, Modifiers::default()) - }; - assert!(encoded(&term, &release).is_empty()); - } -} diff --git a/crates/zeddy/Cargo.toml b/crates/zeddy/Cargo.toml index 5f096b74..9e92d3ca 100644 --- a/crates/zeddy/Cargo.toml +++ b/crates/zeddy/Cargo.toml @@ -11,14 +11,19 @@ default-run = "zeddy" zeddy-herdr = { path = "../zeddy-herdr" } zeddy-plugin = { path = "../zeddy-plugin" } zeddy-plugin-host = { path = "../zeddy-plugin-host" } -zeddy-vt = { path = "../zeddy-vt" } - gpui.workspace = true # The one crate that may name the platform backend. gpui_platform.workspace = true ui.workspace = true theme.workspace = true zed_assets.workspace = true +terminal.workspace = true +terminal_view.workspace = true +editor.workspace = true +settings.workspace = true +task.workspace = true +collections.workspace = true +util.workspace = true anyhow.workspace = true futures.workspace = true diff --git a/crates/zeddy/src/actions.rs b/crates/zeddy/src/actions.rs index 34a48668..1821e929 100644 --- a/crates/zeddy/src/actions.rs +++ b/crates/zeddy/src/actions.rs @@ -3,30 +3,35 @@ //! Keeping actions separate from handlers gives Chartr one command surface //! for keymaps, buttons, menus, and the command palette. +use ::settings::{DEFAULT_KEYMAP_PATH, KeymapFile}; use gpui::{App, KeyBinding}; use crate::keymap::{KeymapAction, KeymapStore}; pub mod pane { gpui::actions!( - pane, + chartr_pane, [CloseActiveItem, CloseAllItems, JoinIntoNext, MoveLeft, MoveRight, MoveUp, MoveDown] ); } pub mod workspace { gpui::actions!( - workspace, + chartr_workspace, [NewTerminal, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePaneDown] ); } pub mod command_palette { - gpui::actions!(command_palette, [Toggle]); + gpui::actions!(chartr_command_palette, [Toggle]); } pub mod settings { - gpui::actions!(settings, [Open]); + gpui::actions!(chartr_settings, [Open]); +} + +pub mod terminal_search { + gpui::actions!(chartr_terminal_search, [Toggle, Next, Previous, Close]); } pub fn init(keymap: &KeymapStore, cx: &mut App) { @@ -45,4 +50,80 @@ pub fn init(keymap: &KeymapStore, cx: &mut App) { KeyBinding::new(keymap.key(KeymapAction::CommandPalette), command_palette::Toggle, context), KeyBinding::new(keymap.key(KeymapAction::OpenSettings), settings::Open, context), ]); + + // Keep terminal behavior aligned with the exact pinned Zed revision. The + // full default keymap also contains editor/workspace bindings Chartr does + // not own, so import only actions implemented by the terminal stack (plus + // Select All, which TerminalView handles explicitly). + cx.bind_keys(upstream_terminal_bindings(cx)); + + #[cfg(target_os = "macos")] + cx.bind_keys([KeyBinding::new("cmd-f", terminal_search::Toggle, Some("Terminal"))]); + + #[cfg(not(target_os = "macos"))] + cx.bind_keys([KeyBinding::new("ctrl-shift-f", terminal_search::Toggle, Some("Terminal"))]); + + cx.bind_keys([ + KeyBinding::new("enter", terminal_search::Next, Some("ChartrTerminalSearch")), + KeyBinding::new("shift-enter", terminal_search::Previous, Some("ChartrTerminalSearch")), + KeyBinding::new("escape", terminal_search::Close, Some("ChartrTerminalSearch")), + ]); + + // Chartr uses Ctrl+K as a pane chord on non-macOS platforms. Override it + // at Terminal context depth so shells still receive their conventional + // kill-to-end-of-line command; the pane chord remains available elsewhere. + #[cfg(not(target_os = "macos"))] + cx.bind_keys([KeyBinding::new("ctrl-k", terminal_send_keystroke("ctrl-k"), Some("Terminal"))]); +} + +fn upstream_terminal_bindings(cx: &App) -> Vec { + KeymapFile::load_asset_allow_partial_failure(DEFAULT_KEYMAP_PATH, cx) + .expect("the pinned Zed terminal keymap must remain loadable") + .into_iter() + .filter(|binding| { + let action = binding.action().name(); + action.starts_with("terminal::") || action == "editor::SelectAll" + }) + .collect() +} + +/// Parameterized actions use the same serialized contract as Zed's keymap. +#[cfg(not(target_os = "macos"))] +fn terminal_send_keystroke(keystroke: &str) -> terminal_view::SendKeystroke { + serde_json::from_value(serde_json::Value::String(keystroke.to_owned())) + .expect("Zed's terminal::SendKeystroke action accepts a string") +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn imports_the_pinned_zed_terminal_keymap(cx: &mut TestAppContext) { + cx.update(|cx| { + let bindings = upstream_terminal_bindings(cx); + let actions = + bindings.iter().map(|binding| binding.action().name()).collect::>(); + let has_binding = |key: &str, action: &str| { + let key = gpui::Keystroke::parse(key).unwrap(); + bindings.iter().any(|binding| { + binding.action().name() == action + && binding.match_keystrokes(std::slice::from_ref(&key)) == Some(false) + }) + }; + + assert!(actions.contains(&"terminal::Copy")); + assert!(actions.contains(&"terminal::Paste")); + assert!(actions.contains(&"terminal::SendText")); + assert!(has_binding("alt-left", "terminal::SendText")); + assert!(has_binding("alt-right", "terminal::SendText")); + assert!(has_binding("shift-pageup", "terminal::ScrollPageUp")); + + #[cfg(target_os = "macos")] + assert!(has_binding("cmd-v", "terminal::Paste")); + #[cfg(not(target_os = "macos"))] + assert!(has_binding("ctrl-shift-v", "terminal::Paste")); + }); + } } diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index af2dbfdd..15ef192b 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -31,16 +31,13 @@ use crate::{ components::ContextMenu, fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, item::PluginItem, - keys, mode::Mode, - palette, persistence::{ SidebarScope, Snapshot, SpaceKind as PersistedSpaceKind, StateStore, WindowState, }, - settings::{PluginSettingsContent, ResolvedSettings, SettingsStore}, - space::{Kind as SpaceKind, Space, name_for}, + settings::{PluginSettingsContent, SettingsStore}, + space::{Kind as SpaceKind, Space, SpaceEvent, name_for}, spaces::{self, Registry}, - terminal::{Appearance, TerminalElement}, text_input::{InputEvent, TextInput}, workspace::{ Axis as PaneAxisDirection, Member, PaneId as LayoutPaneId, SplitDirection, Workspace, @@ -140,6 +137,13 @@ pub struct Zeddy { command_palette_input: Entity, command_palette_query: String, command_palette_selected: usize, + terminal_search_open: bool, + terminal_search_input: Entity, + terminal_search_query: String, + terminal_search_matches: Vec, + terminal_search_active: Option, + terminal_search_generation: u64, + terminal_search_target: Option>, rename_space: Option, rename_group: Option<(EntityId, WorkspaceTabId)>, rename_input: Entity, @@ -151,18 +155,16 @@ pub struct Zeddy { last_persisted: Option, title_bar: Entity, focus: FocusHandle, - /// Presses actually delivered to a terminal, keyed by GPUI's physical key - /// name. A matching release is sent only for one of these, so a release for - /// an application shortcut never leaks into a Kitty-aware TUI. - terminal_keys_down: HashMap, - /// libghostty's safe encoder is window-thread-bound. It is reconfigured - /// from the active session's copyable mode snapshot for every event. - key_encoder: zeddy_vt::KeyEncoder, problem: Option, } impl Zeddy { - pub fn new(cwd: PathBuf, cx: &mut Context) -> Self { + pub fn new(cwd: PathBuf, window: &mut Window, cx: &mut Context) -> Self { + let focus = cx.focus_handle(); + cx.on_focus_in(&focus, window, |this, window, cx| { + this.focus_active_terminal(window, cx); + }) + .detach(); let settings = cx.global::().clone(); cx.observe_global::(|this, cx| { this.settings = cx.global::().clone(); @@ -171,16 +173,20 @@ impl Zeddy { }) .detach(); let command_palette_input = cx.new(|cx| TextInput::new("Type a command…", cx)); + let terminal_search_input = cx.new(|cx| TextInput::new("Find in terminal…", cx)); let rename_input = cx.new(|cx| TextInput::new("Type a name…", cx)); let title_bar = cx.new(|_| crate::title_bar::TitleBar::new("workspace-title-bar")); - let key_encoder = - zeddy_vt::KeyEncoder::new().expect("create the libghostty terminal key encoder"); cx.subscribe(&command_palette_input, |this, input, _: &InputEvent, cx| { this.command_palette_query = input.read(cx).text().to_owned(); this.command_palette_selected = 0; cx.notify(); }) .detach(); + cx.subscribe(&terminal_search_input, |this, input, _: &InputEvent, cx| { + this.terminal_search_query = input.read(cx).text().to_owned(); + this.start_terminal_search(cx); + }) + .detach(); cx.subscribe(&rename_input, |this, input, _: &InputEvent, cx| { this.rename_query = input.read(cx).text().to_owned(); cx.notify(); @@ -218,6 +224,13 @@ impl Zeddy { command_palette_input, command_palette_query: String::new(), command_palette_selected: 0, + terminal_search_open: false, + terminal_search_input, + terminal_search_query: String::new(), + terminal_search_matches: Vec::new(), + terminal_search_active: None, + terminal_search_generation: 0, + terminal_search_target: None, rename_space: None, rename_group: None, rename_input, @@ -228,9 +241,7 @@ impl Zeddy { state, last_persisted: saved_json, title_bar, - focus: cx.focus_handle(), - terminal_keys_down: HashMap::new(), - key_encoder, + focus, problem: Some(state_problem.unwrap_or_else(|| error.to_string())), }; } @@ -284,7 +295,7 @@ impl Zeddy { .into_iter() .map(|(name, path, kind)| { let space = cx.new(|cx| Space::new(name, path, kind, client.clone(), cx)); - cx.observe(&space, |_, _, cx| cx.notify()).detach(); + Self::subscribe_to_space(&space, window, cx); space }) .collect(); @@ -337,6 +348,13 @@ impl Zeddy { command_palette_input, command_palette_query: String::new(), command_palette_selected: 0, + terminal_search_open: false, + terminal_search_input, + terminal_search_query: String::new(), + terminal_search_matches: Vec::new(), + terminal_search_active: None, + terminal_search_generation: 0, + terminal_search_target: None, rename_space: None, rename_group: None, rename_input, @@ -347,15 +365,246 @@ impl Zeddy { state, last_persisted: saved_json, title_bar, - focus: cx.focus_handle(), - terminal_keys_down: HashMap::new(), - key_encoder, + focus, problem: state_problem.or(registry_problem), }; this.connect(cx); this } + fn subscribe_to_space(space: &Entity, window: &mut Window, cx: &mut Context) { + cx.observe_in(space, window, |this, space, window, cx| { + if this.active.as_ref() == Some(&space) + && this.focus.is_focused(window) + && !this.command_palette_open + && !this.terminal_search_open + && this.rename_space.is_none() + && this.rename_group.is_none() + { + this.focus_active_terminal(window, cx); + } + cx.notify(); + }) + .detach(); + cx.subscribe_in(space, window, |this, space, event, window, cx| { + let SpaceEvent::TerminalReady(id) = *event; + let Some(terminal) = space + .read(cx) + .item(id) + .and_then(crate::item::Item::as_session) + .map(|item| item.session.terminal()) + else { + return; + }; + let view = crate::terminal_host::new_view(terminal.clone(), window, cx); + space.update(cx, |space, _| space.install_terminal_view(id, view.clone())); + cx.subscribe(&terminal, |_, _, event, cx| { + let terminal::Event::Open(terminal::MaybeNavigationTarget::PathLike(target)) = + event + else { + return; + }; + if let Some(path) = resolve_terminal_path(target) + && let Ok(url) = url::Url::from_file_path(path) + { + cx.open_url(url.as_str()); + } + }) + .detach(); + let observed_space = space.clone(); + cx.observe(&view, move |_, view, cx| { + let bell = view.read(cx).has_bell(); + if observed_space.update(cx, |space, _| space.set_terminal_bell(id, bell)) { + cx.notify(); + } + }) + .detach(); + + if this.active.as_ref() == Some(space) + && space.read(cx).active() == Some(id) + && !this.command_palette_open + && this.rename_space.is_none() + && this.rename_group.is_none() + { + this.focus_active_terminal(window, cx); + } + cx.notify(); + }) + .detach(); + } + + fn focus_active_terminal(&self, window: &mut Window, cx: &mut App) { + let Some(view) = self + .active + .as_ref() + .and_then(|space| { + let space = space.read(cx); + space.active().and_then(|id| space.item(id)) + }) + .and_then(crate::item::Item::as_session) + .and_then(crate::item::SessionItem::terminal_view) + else { + return; + }; + window.focus(&view.read(cx).focus_handle(cx), cx); + } + + fn active_terminal(&self, cx: &App) -> Option> { + self.active + .as_ref() + .and_then(|space| { + let space = space.read(cx); + space.active().and_then(|id| space.item(id)) + }) + .and_then(crate::item::Item::as_session) + .map(|item| item.session.terminal()) + } + + fn toggle_terminal_search(&mut self, window: &mut Window, cx: &mut Context) { + if self.terminal_search_open { + self.close_terminal_search(window, cx); + return; + } + let Some(terminal) = self.active_terminal(cx) else { + return; + }; + let suggestion = + terminal.read(cx).last_content().selection_text.clone().unwrap_or_default(); + self.terminal_search_open = true; + self.terminal_search_target = Some(terminal); + self.terminal_search_query = suggestion.clone(); + self.terminal_search_input.update(cx, |input, cx| { + input.set_text(suggestion, true, cx); + }); + self.start_terminal_search(cx); + window.focus(&self.terminal_search_input.focus_handle(cx), cx); + cx.notify(); + } + + fn start_terminal_search(&mut self, cx: &mut Context) { + if !self.terminal_search_open { + return; + } + self.terminal_search_generation = self.terminal_search_generation.wrapping_add(1); + let generation = self.terminal_search_generation; + let Some(terminal) = self.terminal_search_target.clone() else { + return; + }; + let query = self.terminal_search_query.clone(); + if query.is_empty() { + terminal.update(cx, |terminal, _| terminal.matches.clear()); + self.terminal_search_matches.clear(); + self.terminal_search_active = None; + cx.notify(); + return; + } + let Some(search) = terminal::Search::new(®ex_escape_literal(&query)) else { + return; + }; + let debounce = cx.background_executor().timer(Duration::from_millis(60)); + cx.spawn(async move |this, cx| { + debounce.await; + let Ok(Some(find)) = this.update(cx, |this, cx| { + if !this.terminal_search_open + || this.terminal_search_generation != generation + || this.terminal_search_target.as_ref() != Some(&terminal) + { + return None; + } + Some(terminal.update(cx, |terminal, cx| terminal.find_matches(search, cx))) + }) else { + return; + }; + let matches = find.await; + let _ = this.update(cx, |this, cx| { + if !this.terminal_search_open + || this.terminal_search_generation != generation + || this.terminal_search_target.as_ref() != Some(&terminal) + { + return; + } + let active = matches.len().checked_sub(1); + terminal.update(cx, |terminal, _| { + terminal.matches = matches.clone(); + if let Some(active) = active { + terminal.activate_match(active); + } + }); + this.terminal_search_matches = matches; + this.terminal_search_active = active; + cx.notify(); + }); + }) + .detach(); + } + + fn navigate_terminal_search(&mut self, forward: bool, cx: &mut Context) { + let count = self.terminal_search_matches.len(); + if count == 0 { + return; + } + let active = match (self.terminal_search_active, forward) { + (Some(active), true) => (active + 1) % count, + (Some(0), false) | (None, false) => count - 1, + (Some(active), false) => active - 1, + (None, true) => 0, + }; + self.terminal_search_active = Some(active); + if let Some(terminal) = self.terminal_search_target.as_ref() { + terminal.update(cx, |terminal, _| terminal.activate_match(active)); + } + cx.notify(); + } + + fn close_terminal_search(&mut self, window: &mut Window, cx: &mut Context) { + self.terminal_search_open = false; + self.terminal_search_generation = self.terminal_search_generation.wrapping_add(1); + self.terminal_search_query.clear(); + self.terminal_search_matches.clear(); + self.terminal_search_active = None; + if let Some(terminal) = self.terminal_search_target.take() { + terminal.update(cx, |terminal, _| terminal.matches.clear()); + } + self.terminal_search_input.update(cx, |input, cx| input.clear(cx)); + self.focus_active_terminal(window, cx); + cx.notify(); + } + + fn terminal_search_overlay(&self, cx: &mut Context) -> Option { + if !self.terminal_search_open { + return None; + } + let count = self.terminal_search_matches.len(); + let current = self.terminal_search_active.map_or(0, |active| active + 1); + let previous = cx.listener(|this, _, _, cx| this.navigate_terminal_search(false, cx)); + let next = cx.listener(|this, _, _, cx| this.navigate_terminal_search(true, cx)); + let close = cx.listener(|this, _, window, cx| this.close_terminal_search(window, cx)); + Some( + h_flex() + .id("terminal-search") + .key_context("ChartrTerminalSearch") + .absolute() + .top_2() + .right_2() + .gap_1() + .p_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .child(div().w(px(220.)).child(self.terminal_search_input.clone())) + .child( + Label::new(format!("{current}/{count}")) + .size(UI_LABEL_SMALL) + .color(Color::Muted), + ) + .child(Button::new("terminal-search-previous", "Prev").on_click(previous)) + .child(Button::new("terminal-search-next", "Next").on_click(next)) + .child(Button::new("terminal-search-close", "Close").on_click(close)) + .into_any_element(), + ) + } + /// Apply the explicit exit policy while the window and its entities are /// still reachable. The default does nothing; `Space::drop` then sends a /// clean release to every attachment so Herdr can be adopted next launch. @@ -677,24 +926,23 @@ impl Zeddy { return; } self.active = Some(space.clone()); - space.update(cx, |space, _| space.fit_items()); window.focus(&self.focus, cx); cx.notify(); } - fn pick_a_folder(&mut self, cx: &mut Context) { + fn pick_a_folder(&mut self, window: &mut Window, cx: &mut Context) { let chosen = cx.prompt_for_paths(PathPromptOptions { files: false, directories: true, multiple: false, prompt: Some("Add".into()), }); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { let outcome = chosen.await; - let _ = this.update(cx, |this, cx| match outcome { + let _ = this.update_in(cx, |this, window, cx| match outcome { Ok(Ok(Some(paths))) => { for path in paths { - this.register(path, cx); + this.register(path, window, cx); } } Ok(Ok(None)) => {} @@ -711,7 +959,7 @@ impl Zeddy { .detach(); } - fn register(&mut self, path: PathBuf, cx: &mut Context) { + fn register(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let Some(registry) = self.registry.as_mut() else { self.problem = Some("the space registry is unavailable".into()); cx.notify(); @@ -742,7 +990,7 @@ impl Zeddy { }; let name = name_for(SpaceKind::Registered, &path); let space = cx.new(|cx| Space::new(name, path, SpaceKind::Registered, client, cx)); - cx.observe(&space, |_, _, cx| cx.notify()).detach(); + Self::subscribe_to_space(&space, window, cx); self.spaces.push(space.clone()); self.active = Some(space); self.problem = None; @@ -892,6 +1140,7 @@ impl Zeddy { } Action::LocateSpace { space } => self.locate_space(space, cx), action @ (Action::Select { .. } | Action::Close { .. }) => { + let selecting = matches!(action, Action::Select { .. }); let target = match &action { Action::Select { space, .. } | Action::Close { space, .. } => space .and_then(|id| self.spaces.iter().find(|space| space.entity_id() == id)) @@ -900,10 +1149,13 @@ impl Zeddy { _ => None, }; if let Some(space) = target { - if matches!(action, Action::Select { .. }) { + if selecting { self.activate(space.clone(), window, cx); } space.update(cx, |space, cx| space.act(action, cx)); + if selecting { + self.focus_active_terminal(window, cx); + } } } } @@ -1580,47 +1832,6 @@ impl Zeddy { cx.notify(); return; } - let key_name = event.keystroke.key.clone(); - if event.is_held && !self.terminal_keys_down.contains_key(&key_name) { - return; - } - let pressed = keys::normalize(&event.keystroke, event.is_held); - if self.send_terminal_key(&pressed, cx) && !event.is_held { - self.terminal_keys_down.insert(key_name, pressed); - } - } - - fn on_key_up(&mut self, event: &gpui::KeyUpEvent, cx: &mut Context) { - let Some(pressed) = self.terminal_keys_down.remove(&event.keystroke.key) else { - return; - }; - let released = keys::released(pressed); - self.send_terminal_key(&released, cx); - } - - /// Encode and deliver an event to the active terminal. Empty encodings are - /// intentionally not tracked: under the active protocol that key has no - /// matching release to deliver either. - fn send_terminal_key(&mut self, event: &zeddy_vt::KeyEvent, cx: &mut Context) -> bool { - let Some(space) = self.active.clone() else { - return false; - }; - let Some(modes) = space.read(cx).active_keyboard_modes() else { - return false; - }; - let bytes = match self.key_encoder.encode(event, modes) { - Ok(bytes) => bytes, - Err(error) => { - self.problem = Some(format!("encoding terminal input: {error}")); - cx.notify(); - return false; - } - }; - if bytes.is_empty() { - return false; - } - space.update(cx, |space, cx| space.send_active(&bytes, cx)); - true } fn space_switcher(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { @@ -1642,8 +1853,8 @@ impl Zeddy { let menu = ContextMenu::build(window, cx, move |menu, _, _| { let add = weak.clone(); - let mut menu = menu.entry("New Space…", None, move |_, cx| { - let _ = add.update(cx, |this, cx| this.pick_a_folder(cx)); + let mut menu = menu.entry("New Space…", None, move |window, cx| { + let _ = add.update(cx, |this, cx| this.pick_a_folder(window, cx)); }); let registered: Vec<_> = @@ -2233,10 +2444,8 @@ impl Zeddy { let Some(space) = self.active.clone() else { return message("No space. Add a folder to begin.", cx).into_any_element(); }; - let (problem, active) = space.update(cx, |space, _| { - space.fit_items(); - (space.problem().map(str::to_owned), space.active()) - }); + let (problem, active) = + space.update(cx, |space, _| (space.problem().map(str::to_owned), space.active())); let on_action = cx.listener(|this, action: &Action, window, cx| this.act(action.clone(), window, cx)); let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); @@ -2264,11 +2473,14 @@ impl Zeddy { message("No tabs. Create a new item to begin.", cx).into_any_element() }; let notices = self.workspace_notices(problem, cx); + let terminal_search = self.terminal_search_overlay(cx); v_flex() + .relative() .size_full() .min_h_0() .children(notices) .child(div().flex_1().min_h_0().child(workspace)) + .children(terminal_search) .into_any_element() } @@ -2480,7 +2692,7 @@ impl Zeddy { show_header: bool, on: &chrome::Emit, weak: &gpui::WeakEntity, - window: &mut Window, + _window: &mut Window, cx: &App, ) -> AnyElement { let Some(pane) = layout.pane(pane_id) else { @@ -2494,76 +2706,68 @@ impl Zeddy { self.empty_pane_header(tab_id, pane_id, weak, cx) } }); - let content = pane - .active() - .and_then(|id| space.item(id).map(|item| (id, item))) - .map(|(id, item)| match item { - crate::item::Item::Session(item) => { - let terminal = terminal( - item, - active_pane && self.focus.is_focused(window), - self.settings.resolved(), - cx, - ) - .into_any_element(); - let ended = item.session.ended(); - let retrying = space.reattaching(id); - let retry = weak.clone(); - v_flex() - .relative() - .size_full() - .child(terminal) - .when_some(ended, |view, ended| { - let detail = match &ended { + let content = + pane.active() + .and_then(|id| space.item(id).map(|item| (id, item))) + .map(|(id, item)| match item { + crate::item::Item::Session(item) => { + let Some(terminal_view) = item.terminal_view() else { + return message("Starting terminal…", cx).into_any_element(); + }; + let terminal = crate::terminal_host::element( + terminal_view, + cx.theme().colors().terminal_background, + ); + let ended = item.session.ended(); + let retrying = space.reattaching(id); + let retry = weak.clone(); + v_flex() + .relative() + .size_full() + .child(terminal) + .when_some(ended, |view, ended| { + let detail = match &ended { crate::session::Ended::Closed => { "Session ended. Close this tab when you are done reviewing it." .to_owned() } - crate::session::Ended::Failed(error) => { - format!("Terminal connection failed: {error}") - } }; - view.child( - div().absolute().left_2().right_2().bottom_2().child( - Banner::new() - .severity(Severity::Error) - .child(Label::new(detail).size(UI_LABEL_DEFAULT)) - .when( - matches!(ended, crate::session::Ended::Failed(_)), - |banner| { - banner.action_slot( - Button::new( - format!("reattach-session-{}", id.get()), - if retrying { - "Reattaching…" - } else { - "Reattach" - }, - ) - .disabled(retrying) - .on_click(move |_, _, cx| { - let _ = retry.update(cx, |this, cx| { - if let Some(space) = this.active.clone() - { - space.update(cx, |space, cx| { - space.reattach(id, cx) - }); - } - }); - }), + view.child( + div().absolute().left_2().right_2().bottom_2().child( + Banner::new() + .severity(Severity::Error) + .child(Label::new(detail).size(UI_LABEL_DEFAULT)) + .action_slot( + Button::new( + format!("reattach-session-{}", id.get()), + if retrying { + "Reattaching…" + } else { + "Reattach" + }, ) - }, - ), - ), - ) - }) + .disabled(retrying) + .on_click(move |_, _, cx| { + let _ = retry.update(cx, |this, cx| { + if let Some(space) = this.active.clone() { + space.update(cx, |space, cx| { + space.reattach(id, cx) + }); + } + }); + }), + ), + ), + ) + }) + .into_any_element() + } + crate::item::Item::Plugin(item) => item.view.clone().into_any_element(), + }) + .unwrap_or_else(|| { + empty_pane_message("Drop a tab here or create a new item.", cx) .into_any_element() - } - crate::item::Item::Plugin(item) => item.view.clone().into_any_element(), - }) - .unwrap_or_else(|| { - empty_pane_message("Drop a tab here or create a new item.", cx).into_any_element() - }); + }); let drag_move = weak.clone(); let drop_item = weak.clone(); @@ -2575,6 +2779,7 @@ impl Zeddy { .active() .and_then(|active| pane.items().iter().position(|item| *item == active)) .unwrap_or(pane.items().len()); + let pane_is_empty = pane.active().is_none(); let drop_direction = space .drag_target() .filter(|(tab, pane, _)| *tab == tab_id && *pane == pane_id) @@ -2589,12 +2794,14 @@ impl Zeddy { .when(pane.active().is_none() && active_pane, |pane| { pane.role(Role::Group).aria_label("Empty pane").tab_group().tab_index(0) }) - .capture_any_mouse_down(move |_, window, cx| { + .on_any_mouse_down(move |_, window, cx| { let _ = focus_pane.update(cx, |this, cx| { if let Some(space) = this.active.clone() { space.update(cx, |space, _| space.activate_pane(tab_id, pane_id)); } - window.focus(&this.focus, cx); + if pane_is_empty { + window.focus(&this.focus, cx); + } cx.notify(); }); }) @@ -2709,6 +2916,7 @@ impl Zeddy { let status = item.status(); let process_running = item.process_running(); let ended = item.ended(); + let bell = item.as_session().is_some_and(crate::item::SessionItem::bell); let position = chrome::tab_position(index, pane.items().len(), active_index); let select = *id; let close = *id; @@ -2747,7 +2955,7 @@ impl Zeddy { &space_key, *id, ) - .activity(status, process_running, ended) + .activity(chrome::Activity { status, process_running, ended, bell }) .close_slot(Some(close_slot)) .build(cx) .on_click(move |_, window, cx| { @@ -3265,8 +3473,19 @@ impl Render for Zeddy { .on_action(cx.listener(|this, _: &actions::command_palette::Toggle, window, cx| { this.toggle_command_palette(window, cx) })) + .on_action(cx.listener(|this, _: &actions::terminal_search::Toggle, window, cx| { + this.toggle_terminal_search(window, cx) + })) + .on_action(cx.listener(|this, _: &actions::terminal_search::Next, _, cx| { + this.navigate_terminal_search(true, cx) + })) + .on_action(cx.listener(|this, _: &actions::terminal_search::Previous, _, cx| { + this.navigate_terminal_search(false, cx) + })) + .on_action(cx.listener(|this, _: &actions::terminal_search::Close, window, cx| { + this.close_terminal_search(window, cx) + })) .on_key_down(cx.listener(|this, event, window, cx| this.on_key(event, window, cx))) - .on_key_up(cx.listener(|this, event, _, cx| this.on_key_up(event, cx))) .child(title_bar) .child(body) .children(command_palette) @@ -3275,6 +3494,52 @@ impl Render for Zeddy { } } +fn regex_escape_literal(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + if matches!( + character, + '.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' + ) { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +fn resolve_terminal_path(target: &terminal::PathLikeTarget) -> Option { + let base = target.working_directory.as_deref(); + let resolve = |text: &str| { + let path = PathBuf::from(text); + if path.is_absolute() { + path + } else if let Some(base) = base { + base.join(path) + } else { + path + } + }; + let direct = resolve(&target.maybe_path); + if direct.exists() { + return Some(direct); + } + + let mut path = target.maybe_path.as_str(); + for _ in 0..2 { + let (candidate, suffix) = path.rsplit_once(':')?; + if suffix.parse::().is_err() { + return None; + } + path = candidate; + let resolved = resolve(path); + if resolved.exists() { + return Some(resolved); + } + } + None +} + /// Seats every space the current registry/state can recover in the last saved /// full-vector order. Registry-only additions retain file order at the end; /// an older snapshot that predates the synthetic Free entry gets that entry at @@ -3409,56 +3674,6 @@ fn load_registry(cwd: &std::path::Path) -> (Option, Option) { (Some(registry), None) } -fn terminal( - item: &crate::item::SessionItem, - focused: bool, - settings: &ResolvedSettings, - cx: &App, -) -> impl IntoElement { - let theme = cx.theme(); - let screen = item.session.screen(); - let fit = item.fit.clone(); - let session = item.session.access(); - let colors = screen - .rows - .iter() - .map(|row| row.iter().map(|cell| palette::cell_colors(cell, theme)).collect()) - .collect(); - - let (font, font_size, line_height) = Fonts::from_settings(settings).terminal(); - let appearance = Appearance { - font, - font_size, - line_height, - background: theme.colors().terminal_background, - cursor: theme.colors().terminal_foreground, - }; - - v_flex() - .size_full() - .p_2() - .bg(theme.colors().terminal_background) - .on_scroll_wheel(move |event, window, cx| { - let Some(lines) = fit.wheel_lines(event) else { - return; - }; - let Some(position) = fit.cell_at(event.position) else { - return; - }; - let modifiers = zeddy_vt::Modifiers { - shift: event.modifiers.shift, - alt: event.modifiers.alt, - control: event.modifiers.control, - super_key: event.modifiers.platform, - }; - if session.wheel(zeddy_vt::WheelEvent { lines, position, modifiers }) { - window.refresh(); - } - cx.stop_propagation(); - }) - .child(TerminalElement::new(screen, colors, appearance, focused, item.fit.clone())) -} - fn message(text: &str, cx: &App) -> impl IntoElement { v_flex() .size_full() @@ -3490,7 +3705,27 @@ fn plugin_paths() -> Paths { #[cfg(test)] mod pane_drop_tests { - use super::{SplitDirection, pane_drop_direction_for_position, split_direction_for_position}; + use super::{ + SplitDirection, pane_drop_direction_for_position, regex_escape_literal, + resolve_terminal_path, split_direction_for_position, + }; + + #[test] + fn terminal_search_treats_user_text_as_a_literal() { + assert_eq!(regex_escape_literal("a.b[c]+(d)?\\e"), "a\\.b\\[c\\]\\+\\(d\\)\\?\\\\e"); + } + + #[test] + fn terminal_paths_resolve_relative_locations_without_inventing_an_editor() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("example.rs"); + std::fs::write(&file, "fn main() {}\n").unwrap(); + let target = terminal::PathLikeTarget { + maybe_path: "example.rs:12:3".to_owned(), + working_directory: Some(directory.path().to_path_buf()), + }; + assert_eq!(resolve_terminal_path(&target), Some(file)); + } #[test] fn panes_outside_the_pointer_do_not_overwrite_the_hovered_panes_drop_target() { diff --git a/crates/zeddy/src/chrome.rs b/crates/zeddy/src/chrome.rs index dec95ed2..fff2e97f 100644 --- a/crates/zeddy/src/chrome.rs +++ b/crates/zeddy/src/chrome.rs @@ -70,9 +70,7 @@ pub(crate) struct ItemTab<'a> { aria_label: SharedString, selected: bool, position: TabPosition, - status: Option, - process_running: bool, - ended: bool, + activity: Activity, grouped: bool, space: &'a str, key: ItemId, @@ -95,9 +93,7 @@ impl<'a> ItemTab<'a> { title, selected, position, - status: None, - process_running: false, - ended: false, + activity: Activity::default(), grouped: false, space, key, @@ -110,15 +106,8 @@ impl<'a> ItemTab<'a> { self } - pub(crate) fn activity( - mut self, - status: Option, - process_running: bool, - ended: bool, - ) -> Self { - self.status = status; - self.process_running = process_running; - self.ended = ended; + pub(crate) fn activity(mut self, activity: Activity) -> Self { + self.activity = activity; self } @@ -139,15 +128,7 @@ impl<'a> ItemTab<'a> { .aria_selected(self.selected) .position(self.position) .toggle_state(self.selected) - .start_slot(status_indicator( - self.status, - self.process_running, - self.ended, - self.grouped, - self.space, - self.key, - cx, - )) + .start_slot(status_indicator(self.activity, self.grouped, self.space, self.key, cx)) .end_slot::(self.close_slot) .child(tab_label(self.title, self.selected)) } @@ -171,11 +152,32 @@ pub struct Entry { /// A session whose reader has stopped is still listed — closing it is the /// user's decision, not something that happens to them. pub ended: bool, + /// Zed's terminal emulator received BEL since the terminal last handled input. + pub bell: bool, pub selected: bool, pub closable: bool, pub grouped: bool, } +impl Entry { + pub(crate) fn activity(&self) -> Activity { + Activity { + status: self.status, + process_running: self.process_running, + ended: self.ended, + bell: self.bell, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Activity { + pub status: Option, + pub process_running: bool, + pub ended: bool, + pub bell: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpaceEntries { pub id: EntityId, @@ -321,9 +323,7 @@ impl Render for DraggedItemPreview { /// animation primitive. A plain foreground process gets a slower neutral /// spinner so it cannot be mistaken for an agent actively working. pub fn status_indicator( - status: Option, - process_running: bool, - ended: bool, + activity: Activity, grouped: bool, space: &str, key: ItemId, @@ -332,14 +332,17 @@ pub fn status_indicator( let slot = || div().flex_none().size(px(12.)).flex().items_center().justify_center(); let icon = |name, color| Icon::new(name).size(IconSize::XSmall).color(color); - if ended { + if activity.ended { return slot().child(icon(IconName::XCircle, Color::Error)).into_any_element(); } if grouped { return slot().child(icon(IconName::Split, Color::Muted)).into_any_element(); } + if activity.bell { + return slot().child(icon(IconName::BellRing, Color::Warning)).into_any_element(); + } - match status { + match activity.status { Some(SessionStatus::Working) => { slot() .child(icon(IconName::LoadCircle, Color::Accent).with_keyed_rotate_animation( @@ -354,7 +357,7 @@ pub fn status_indicator( Some(SessionStatus::Done) => { slot().child(icon(IconName::Check, Color::Success)).into_any_element() } - Some(SessionStatus::Idle | SessionStatus::Unknown) if process_running => { + Some(SessionStatus::Idle | SessionStatus::Unknown) if activity.process_running => { slot() .child(icon(IconName::LoadCircle, Color::Muted).with_keyed_rotate_animation( format!("process-status-{space}-{}", key.get()), diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index cf5bd010..7f97bb81 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -782,9 +782,7 @@ fn row( on(Action::Select { space: Some(space), item: select }, window, cx) }) .start_slot(status_indicator( - entry.status, - entry.process_running, - entry.ended, + entry.activity(), entry.grouped, &entry.space_key, entry.key, diff --git a/crates/zeddy/src/chrome/tabs.rs b/crates/zeddy/src/chrome/tabs.rs index a397748e..02168203 100644 --- a/crates/zeddy/src/chrome/tabs.rs +++ b/crates/zeddy/src/chrome/tabs.rs @@ -113,7 +113,7 @@ fn tab( entry.key, ) .aria_label(aria_label) - .activity(entry.status, entry.process_running, entry.ended) + .activity(entry.activity()) .grouped(entry.grouped) .close_slot(close_slot) .build(cx) diff --git a/crates/zeddy/src/fonts.rs b/crates/zeddy/src/fonts.rs index 390d3572..23bd6261 100644 --- a/crates/zeddy/src/fonts.rs +++ b/crates/zeddy/src/fonts.rs @@ -9,6 +9,8 @@ use std::borrow::Cow; use gpui::{App, Font, Pixels, Rems, Window, px}; +use settings::Settings as _; +use terminal::terminal_settings::TerminalSettings; use theme::{ThemeSettingsProvider, UiDensity}; use ui::LabelSize; @@ -44,6 +46,23 @@ pub fn load_bundled(cx: &App) -> anyhow::Result<()> { cx.text_system().add_fonts(vec![Cow::Borrowed(IBM_PLEX_MONO)]) } +/// Install Chartr's resolved typography at the two native Zed settings +/// boundaries that consume it. UI components use `ThemeSettingsProvider`, +/// while a standalone `TerminalElement` deliberately gives the terminal's own +/// font override precedence. Keeping both in sync lets TerminalView perform its +/// normal relayout and PTY resize when typography changes. +pub fn install(settings: &ResolvedSettings, cx: &mut App) { + theme::set_theme_settings_provider(Box::new(Fonts::from_settings(settings)), cx); + + if let Some(mut terminal_settings) = TerminalSettings::try_get(cx).cloned() { + terminal_settings.font_family = Some(settings.terminal_font_family.clone().into()); + terminal_settings.font_size = Some(px(settings.terminal_font_size)); + TerminalSettings::override_global(terminal_settings, cx); + } + + cx.refresh_windows(); +} + impl Default for Fonts { fn default() -> Self { Self::from_settings(&ResolvedSettings::default()) @@ -60,16 +79,6 @@ impl Fonts { } } - /// The terminal's font and the line height to draw it at. - /// - /// The ratio is the one every terminal uses and nobody writes down: a line - /// box about 1.4× the point size, which leaves box-drawing characters - /// touching and leaves text legible. - pub fn terminal(&self) -> (Font, Pixels, Pixels) { - let size = self.buffer_size; - (self.buffer.clone(), size, (size * 1.4).round()) - } - /// Install the configured interface type scale on a window and return the /// font its root should inherit. This is the same boundary as Zed's /// `setup_ui_font`: `ui_font_size` is the root rem, so every UI component @@ -100,7 +109,7 @@ impl ThemeSettingsProvider for Fonts { } fn buffer_font_size(&self, _: &App) -> Pixels { - self.terminal().1 + self.buffer_size } fn ui_density(&self, _: &App) -> UiDensity { @@ -112,11 +121,20 @@ impl ThemeSettingsProvider for Fonts { mod tests { use super::*; - #[test] - fn the_terminal_line_box_leaves_room_for_descenders() { - let (_, size, line_height) = Fonts::default().terminal(); - assert!(line_height > size, "glyphs would clip"); - assert!(line_height < size * 2., "the grid would look double-spaced"); + #[gpui::test] + fn installs_terminal_typography_in_zeds_native_settings(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + ::settings::init(cx); + let mut settings = ResolvedSettings::default(); + settings.terminal_font_family = "IBM Plex Mono".to_owned(); + settings.terminal_font_size = 19.; + + install(&settings, cx); + + let native = TerminalSettings::get_global(cx); + assert_eq!(native.font_size, Some(px(19.))); + assert_eq!(native.font_family.as_ref().map(AsRef::as_ref), Some("IBM Plex Mono")); + }); } #[test] diff --git a/crates/zeddy/src/item.rs b/crates/zeddy/src/item.rs index 458163ec..12dd1fe7 100644 --- a/crates/zeddy/src/item.rs +++ b/crates/zeddy/src/item.rs @@ -5,10 +5,10 @@ //! items. Opening a plugin creates one `PluginItem`, just as attaching a Herdr //! session creates one `SessionItem`. -use gpui::AnyView; +use gpui::{AnyView, Entity}; use zeddy_plugin::PaneKey; -use crate::{session::Session, terminal::Fit}; +use crate::session::Session; pub enum Item { Session(SessionItem), @@ -62,12 +62,38 @@ impl Item { pub struct SessionItem { pub session: Session, - pub fit: Fit, + view: Option>, + bell: bool, } impl SessionItem { pub fn new(session: Session) -> Self { - Self { session, fit: Fit::default() } + Self { session, view: None, bell: false } + } + + pub fn terminal_view(&self) -> Option> { + self.view.clone() + } + + pub fn install_terminal_view(&mut self, view: Entity) { + self.view = Some(view); + } + + pub fn clear_terminal_view(&mut self) { + self.view = None; + self.bell = false; + } + + pub fn bell(&self) -> bool { + self.bell + } + + pub fn set_bell(&mut self, bell: bool) -> bool { + if self.bell == bell { + return false; + } + self.bell = bell; + true } } diff --git a/crates/zeddy/src/keys.rs b/crates/zeddy/src/keys.rs deleted file mode 100644 index 625d4dfa..00000000 --- a/crates/zeddy/src/keys.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! The platform keyboard normalized for the VT boundary. -//! -//! GPUI describes a keystroke with its platform key name, produced text and -//! modifiers. This module preserves those facts in Zeddy's normalized event; -//! `zeddy-vt` and libghostty decide which terminal bytes they mean. - -use gpui::{Keystroke, Modifiers as WindowModifiers}; -use zeddy_vt::{KeyAction, KeyCode, KeyEvent, Modifiers}; - -/// Normalize a key press or auto-repeat without choosing a terminal encoding. -pub fn normalize(keystroke: &Keystroke, held: bool) -> KeyEvent { - KeyEvent { - code: code_of(&keystroke.key), - text: typed(keystroke), - action: if held { KeyAction::Repeat } else { KeyAction::Press }, - modifiers: modifiers_of(keystroke.modifiers), - // GPUI does not currently expose consumed modifiers or IME composition - // state on a Keystroke. Keeping them explicit avoids inventing facts and - // leaves the encoder boundary ready when the platform API grows them. - consumed_modifiers: Modifiers::default(), - composing: false, - } -} - -/// Turn a press previously delivered to the terminal into its matching release. -pub fn released(mut pressed: KeyEvent) -> KeyEvent { - pressed.text = None; - pressed.action = KeyAction::Release; - pressed -} - -/// Text after Shift/layout processing but before Control/Alt transformations. -/// -/// Platforms commonly put the already-encoded C0 byte in `key_char` for -/// Control chords. Passing that through would decide the protocol before -/// Ghostty sees the event. Recover the printable physical character instead; -/// Ghostty can then choose C0, fixterms, modifyOtherKeys, or Kitty encoding. -fn typed(keystroke: &Keystroke) -> Option { - match keystroke.key_char.as_deref() { - Some(text) if !text.is_empty() && !text.chars().any(char::is_control) => { - Some(text.to_owned()) - } - Some(_) | None if keystroke.modifiers.control => physical_text(keystroke), - _ => None, - } -} - -fn physical_text(keystroke: &Keystroke) -> Option { - if keystroke.key == "space" { - return Some(" ".to_owned()); - } - let character = single(&keystroke.key)?; - let character = if keystroke.modifiers.shift { shifted_ascii(character) } else { character }; - Some(character.to_string()) -} - -/// The conventional shifted ASCII face of a physical key. This is needed only -/// when a platform replaced a Control chord's text with its C0 byte. -fn shifted_ascii(character: char) -> char { - match character { - 'a'..='z' => character.to_ascii_uppercase(), - '`' => '~', - '1' => '!', - '2' => '@', - '3' => '#', - '4' => '$', - '5' => '%', - '6' => '^', - '7' => '&', - '8' => '*', - '9' => '(', - '0' => ')', - '-' => '_', - '=' => '+', - '[' => '{', - ']' => '}', - '\\' => '|', - ';' => ':', - '\'' => '"', - ',' => '<', - '.' => '>', - '/' => '?', - _ => character, - } -} - -fn code_of(key: &str) -> KeyCode { - match key { - "space" => KeyCode::Space, - "enter" => KeyCode::Enter, - "tab" => KeyCode::Tab, - "escape" => KeyCode::Escape, - "backspace" => KeyCode::Backspace, - "delete" => KeyCode::Delete, - "insert" => KeyCode::Insert, - "home" => KeyCode::Home, - "end" => KeyCode::End, - "pageup" => KeyCode::PageUp, - "pagedown" => KeyCode::PageDown, - "up" => KeyCode::ArrowUp, - "down" => KeyCode::ArrowDown, - "left" => KeyCode::ArrowLeft, - "right" => KeyCode::ArrowRight, - "back" => KeyCode::BrowserBack, - "forward" => KeyCode::BrowserForward, - "copy" => KeyCode::Copy, - "cut" => KeyCode::Cut, - "paste" => KeyCode::Paste, - _ => function_key(key) - .or_else(|| single(key).and_then(KeyCode::typing)) - .unwrap_or(KeyCode::Unidentified), - } -} - -fn function_key(key: &str) -> Option { - let number: u8 = key.strip_prefix('f')?.parse().ok()?; - Some(match number { - 1 => KeyCode::F1, - 2 => KeyCode::F2, - 3 => KeyCode::F3, - 4 => KeyCode::F4, - 5 => KeyCode::F5, - 6 => KeyCode::F6, - 7 => KeyCode::F7, - 8 => KeyCode::F8, - 9 => KeyCode::F9, - 10 => KeyCode::F10, - 11 => KeyCode::F11, - 12 => KeyCode::F12, - 13 => KeyCode::F13, - 14 => KeyCode::F14, - 15 => KeyCode::F15, - 16 => KeyCode::F16, - 17 => KeyCode::F17, - 18 => KeyCode::F18, - 19 => KeyCode::F19, - 20 => KeyCode::F20, - 21 => KeyCode::F21, - 22 => KeyCode::F22, - 23 => KeyCode::F23, - 24 => KeyCode::F24, - 25 => KeyCode::F25, - _ => return None, - }) -} - -fn single(key: &str) -> Option { - let mut characters = key.chars(); - let first = characters.next()?; - characters.next().is_none().then_some(first) -} - -fn modifiers_of(modifiers: WindowModifiers) -> Modifiers { - Modifiers { - shift: modifiers.shift, - alt: modifiers.alt, - control: modifiers.control, - super_key: modifiers.platform, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn keystroke(key: &str, text: Option<&str>) -> Keystroke { - Keystroke { - modifiers: WindowModifiers::default(), - key: key.to_owned(), - key_char: text.map(str::to_owned), - } - } - - #[test] - fn names_printable_navigation_and_function_keys() { - assert_eq!(code_of("a"), KeyCode::A); - assert_eq!(code_of("/"), KeyCode::Slash); - assert_eq!(code_of("left"), KeyCode::ArrowLeft); - assert_eq!(code_of("f20"), KeyCode::F20); - assert_eq!(code_of("f25"), KeyCode::F25); - assert_eq!(code_of("f26"), KeyCode::Unidentified); - } - - #[test] - fn preserves_platform_text_instead_of_rederiving_it() { - let event = normalize(&keystroke("e", Some("é")), false); - assert_eq!(event.code, KeyCode::E); - assert_eq!(event.text.as_deref(), Some("é")); - } - - #[test] - fn recovers_printable_text_from_platform_control_bytes() { - let mut control_i = keystroke("i", Some("\t")); - control_i.modifiers.control = true; - assert_eq!(normalize(&control_i, false).text.as_deref(), Some("i")); - - let mut control_question = keystroke("/", Some("\u{7f}")); - control_question.modifiers.control = true; - control_question.modifiers.shift = true; - assert_eq!(normalize(&control_question, false).text.as_deref(), Some("?")); - } - - #[test] - fn held_and_released_keys_keep_their_identity() { - let mut input = keystroke("left", None); - input.modifiers.alt = true; - let repeated = normalize(&input, true); - assert_eq!(repeated.action, KeyAction::Repeat); - assert!(repeated.modifiers.alt); - - let release = released(repeated); - assert_eq!(release.action, KeyAction::Release); - assert_eq!(release.code, KeyCode::ArrowLeft); - assert_eq!(release.text, None); - assert!(release.modifiers.alt); - } - - #[test] - fn the_original_missing_chords_reach_ghostty_intact() { - let mut encoder = zeddy_vt::KeyEncoder::new().unwrap(); - - let mut shifted_enter = keystroke("enter", Some("\n")); - shifted_enter.modifiers.shift = true; - let event = normalize(&shifted_enter, false); - assert_eq!( - encoder.encode(&event, zeddy_vt::KeyboardModes::default()).unwrap(), - b"\x1b[27;2;13~", - ); - - let mut option_left = keystroke("left", None); - option_left.modifiers.alt = true; - let event = normalize(&option_left, false); - assert_eq!( - encoder.encode(&event, zeddy_vt::KeyboardModes::default()).unwrap(), - b"\x1b[1;3D", - ); - } - - #[test] - fn control_i_remains_distinct_from_tab() { - let mut encoder = zeddy_vt::KeyEncoder::new().unwrap(); - let mut control_i = keystroke("i", Some("\t")); - control_i.modifiers.control = true; - - assert_eq!( - encoder - .encode(&normalize(&control_i, false), zeddy_vt::KeyboardModes::default()) - .unwrap(), - b"\x1b[105;5u", - ); - } -} diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index 68a11d5d..a6a4b587 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -14,16 +14,14 @@ mod components; mod fonts; mod item; mod keymap; -mod keys; mod mode; -mod palette; mod persistence; mod session; mod settings; mod settings_window; mod space; mod spaces; -mod terminal; +mod terminal_host; mod text_input; mod title_bar; mod web_plugin; @@ -33,6 +31,12 @@ fn main() { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); application().with_assets(zed_assets::Assets).run(move |cx: &mut App| { + // Zed's terminal model/view keeps its native emulator settings graph + // (cursor, scrollback, mouse behavior, and escape-sequence policy). + // Chartr owns product settings and adapts its terminal typography into + // Zed's shared theme provider below; it does not duplicate shell or PTY + // settings that belong to Herdr's persistent session. + ::settings::init(cx); let settings = settings::settings_file() .map(settings::SettingsStore::load) .unwrap_or_else(|_| settings::SettingsStore::bare()); @@ -53,10 +57,7 @@ fn main() { } // Zed's components read their font through this, and zeddy has no // settings file for the `theme_settings` crate to read one from. - theme::set_theme_settings_provider( - Box::new(fonts::Fonts::from_settings(settings.resolved())), - cx, - ); + fonts::install(settings.resolved(), cx); actions::init(&keymap, cx); text_input::init(cx); settings_window::init(&keymap, cx); @@ -103,7 +104,7 @@ fn main() { ..Default::default() }, |window, cx| { - let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), cx)); + let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), window, cx)); window.focus(&view.read(cx).focus_handle(cx), cx); view }, diff --git a/crates/zeddy/src/palette.rs b/crates/zeddy/src/palette.rs deleted file mode 100644 index b38707b7..00000000 --- a/crates/zeddy/src/palette.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Turning a cell's colour into a colour the window can paint. -//! -//! [`zeddy_vt::Color`] deliberately does not resolve anything: it says -//! "indexed 4" or "default", and *what those are* belongs to the theme. This is -//! where that is decided, and it is the only place — so switching themes is a -//! re-render rather than a re-parse. - -use gpui::{Hsla, Rgba}; -use theme::Theme; -use zeddy_vt::{Cell, Color, Style}; - -/// Whether a colour is standing in for the foreground or the background. -/// -/// [`Color::Default`] means different things in the two positions, and this is -/// how the caller says which one it is asking about. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Slot { - Foreground, - Background, -} - -/// Resolve one colour against the active theme. -pub fn resolve(color: Color, slot: Slot, theme: &Theme) -> Hsla { - match color { - Color::Default => match slot { - Slot::Foreground => theme.colors().terminal_foreground, - Slot::Background => theme.colors().terminal_background, - }, - Color::Rgb(r, g, b) => { - Rgba { r: f32::from(r) / 255., g: f32::from(g) / 255., b: f32::from(b) / 255., a: 1. } - .into() - } - Color::Indexed(index) => indexed(index, slot, theme), - } -} - -/// The foreground and background a cell is actually painted with, after -/// `inverse` and `dim` have been applied. -/// -/// Applied here rather than in the VT crate because both depend on what the -/// theme's defaults are, and the VT crate does not have a theme. -pub fn cell_colors(cell: &Cell, theme: &Theme) -> (Hsla, Hsla) { - let Style { inverse, dim, .. } = cell.style; - let (fg_color, bg_color) = if inverse { (cell.bg, cell.fg) } else { (cell.fg, cell.bg) }; - let (fg_slot, bg_slot) = if inverse { - (Slot::Background, Slot::Foreground) - } else { - (Slot::Foreground, Slot::Background) - }; - - let mut fg = resolve(fg_color, fg_slot, theme); - if dim { - fg.a *= 0.7; - } - (fg, resolve(bg_color, bg_slot, theme)) -} - -/// The sixteen ANSI slots, plus the 256-colour cube and greyscale ramp. -/// -/// Zed's theme names the sixteen; 16..=255 are the xterm cube, which is defined -/// arithmetically and is not a theme's to override. -fn indexed(index: u8, slot: Slot, theme: &Theme) -> Hsla { - let colors = theme.colors(); - match index { - 0 => colors.terminal_ansi_black, - 1 => colors.terminal_ansi_red, - 2 => colors.terminal_ansi_green, - 3 => colors.terminal_ansi_yellow, - 4 => colors.terminal_ansi_blue, - 5 => colors.terminal_ansi_magenta, - 6 => colors.terminal_ansi_cyan, - 7 => colors.terminal_ansi_white, - 8 => colors.terminal_ansi_bright_black, - 9 => colors.terminal_ansi_bright_red, - 10 => colors.terminal_ansi_bright_green, - 11 => colors.terminal_ansi_bright_yellow, - 12 => colors.terminal_ansi_bright_blue, - 13 => colors.terminal_ansi_bright_magenta, - 14 => colors.terminal_ansi_bright_cyan, - 15 => colors.terminal_ansi_bright_white, - 16..=231 => { - // The 6×6×6 cube. The steps are xterm's, not evenly spaced: the - // first is 0 and the rest are 95 + 40n. - let value = index - 16; - let step = |n: u8| match n { - 0 => 0u8, - n => 95 + 40 * (n - 1), - }; - let (r, g, b) = (step(value / 36), step((value % 36) / 6), step(value % 6)); - resolve(Color::Rgb(r, g, b), slot, theme) - } - 232..=255 => { - let level = 8 + 10 * (index - 232); - resolve(Color::Rgb(level, level, level), slot, theme) - } - } -} - -#[cfg(test)] -mod tests { - //! Run against the theme the app actually boots with, rather than a - //! hand-built one: what these assert is that the mapping agrees with Zed's - //! palette, and a fixture theme could not tell us that. - - use super::*; - use gpui::TestAppContext; - use theme::ActiveTheme as _; - use zeddy_vt::Style; - - fn cell(fg: Color, bg: Color, style: Style) -> Cell { - Cell { ch: 'x', fg, bg, style } - } - - fn with_theme(cx: &mut TestAppContext, f: impl FnOnce(&Theme) -> R) -> R { - cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); - f(cx.theme()) - }) - } - - #[gpui::test] - fn default_means_something_different_in_each_slot(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - assert_ne!( - resolve(Color::Default, Slot::Foreground, theme), - resolve(Color::Default, Slot::Background, theme) - ); - }); - } - - #[gpui::test] - fn true_colour_is_passed_through_untouched(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - let painted = resolve(Color::Rgb(255, 0, 0), Slot::Foreground, theme); - assert_eq!(painted, Hsla::from(Rgba { r: 1., g: 0., b: 0., a: 1. })); - }); - } - - #[gpui::test] - fn the_sixteen_ansi_slots_come_from_the_theme(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - assert_eq!( - resolve(Color::Indexed(1), Slot::Foreground, theme), - theme.colors().terminal_ansi_red - ); - assert_eq!( - resolve(Color::Indexed(9), Slot::Foreground, theme), - theme.colors().terminal_ansi_bright_red - ); - }); - } - - #[gpui::test] - fn the_cube_follows_xterms_uneven_steps(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - // 16 is the cube's black corner, 231 its white one. - assert_eq!( - resolve(Color::Indexed(16), Slot::Foreground, theme), - resolve(Color::Rgb(0, 0, 0), Slot::Foreground, theme) - ); - assert_eq!( - resolve(Color::Indexed(231), Slot::Foreground, theme), - resolve(Color::Rgb(255, 255, 255), Slot::Foreground, theme) - ); - }); - } - - #[gpui::test] - fn the_greyscale_ramp_is_grey(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - let grey = resolve(Color::Indexed(240), Slot::Foreground, theme); - assert_eq!(grey.s, 0., "a ramp entry with saturation is not grey"); - }); - } - - #[gpui::test] - fn inverse_swaps_the_two_slots_and_not_merely_the_two_colours(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - let plain = cell(Color::Default, Color::Default, Style::default()); - let inverted = - cell(Color::Default, Color::Default, Style { inverse: true, ..Default::default() }); - assert_eq!(cell_colors(&plain, theme), { - let (fg, bg) = cell_colors(&inverted, theme); - (bg, fg) - }); - }); - } - - #[gpui::test] - fn dim_fades_the_foreground_and_leaves_the_background_alone(cx: &mut TestAppContext) { - with_theme(cx, |theme| { - let dimmed = - cell(Color::Indexed(2), Color::Default, Style { dim: true, ..Default::default() }); - let (fg, bg) = cell_colors(&dimmed, theme); - assert!(fg.a < 1.0); - assert_eq!(bg, theme.colors().terminal_background); - }); - } -} diff --git a/crates/zeddy/src/session.rs b/crates/zeddy/src/session.rs index 5a30e324..2a52661b 100644 --- a/crates/zeddy/src/session.rs +++ b/crates/zeddy/src/session.rs @@ -1,137 +1,108 @@ -//! One live session: an attachment, an emulator, and the thread between them. +//! One persistent Herdr session hosted by Zed's complete terminal engine. //! -//! # Why a thread and not a task -//! -//! [`Frames::next_frame`] blocks until herdr has something to say, which for an idle -//! session is never. Parking a GPUI executor task on that would hold an -//! executor thread hostage per idle session, so each attachment gets a real -//! thread of its own, and the only thing that crosses back to the window is a -//! wakeup. -//! -//! # Why the window never reads a frame -//! -//! The reader thread applies frames to the emulator itself, under a mutex, and -//! then says only "something changed". The window's job is to take a snapshot -//! when it paints. That keeps frame application off the frame path entirely: a -//! session producing a thousand repaints a second costs the window one redraw -//! per vsync, not a thousand. - -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, -}; - -use futures::channel::mpsc; -use zeddy_herdr::{ - Geometry, PaneId, - control::{self, Client}, - stream::{Frame, Input}, -}; -use zeddy_vt::{KeyboardModes, Screen, ScrollResult, Size, Terminal, WheelEvent, WheelFallback}; - -const HISTORY_LINES: u32 = 10_000; - -/// A wakeup from a session's reader thread. Carries nothing: the state is in -/// the emulator, and the message only says to look at it. -pub type Wakeup = (); - -/// The reader half's outcome, once it stops. +//! Herdr still owns the long-lived PTY. Chartr launches Herdr's interactive +//! `terminal attach` client inside a local PTY created by Zed, so Zed receives +//! ordinary terminal bytes and owns emulation, rendering, resizing, keyboard, +//! paste, selection, and mouse reporting as one coherent implementation. + +use std::time::Duration; + +use collections::HashMap; +use futures::{StreamExt as _, channel::mpsc}; +use gpui::{App, AppContext as _, Context, Entity, Task}; +use settings::Settings as _; +use task::Shell; +use terminal::{Terminal, TerminalBuilder, terminal_settings::TerminalSettings}; +use util::paths::PathStyle; +use zeddy_herdr::{PaneId, control}; + +/// The local attach client's outcome, once it stops. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Ended { - /// herdr closed the stream: the session exited, or something else took it. + /// The attach client closed. The server-owned session may still be alive. Closed, - /// The stream broke. The message is the one to show in the pane. - Failed(String), } -/// One attached session. +/// One attached session. Dropping the terminal ends only the local Herdr +/// client; Herdr's server-owned PTY remains available for the next attachment. pub struct Session { pub info: control::Session, - terminal: Arc>, - ended: Arc>>, - input: Arc>, - client: Client, - wakeups: mpsc::UnboundedSender, - history_loading: Arc, - pending_scroll: Arc>, - size: Size, + terminal: Entity, + ended: Option, + input_tx: mpsc::UnboundedSender>, + _input_task: Task<()>, } impl Session { - /// Attach to a pane and start reading it. - /// - /// The wakeup sender is cloned per session; the app holds one receiver for - /// all of them and redraws when any session speaks. - pub fn attach( - client: &Client, + /// Build Zed's PTY around Herdr's namespace-safe direct-attach command. + pub fn attach_builder( + client: &control::Client, + info: &control::Session, + window_id: u64, + cx: &App, + ) -> Task> { + let attach = client.direct_attach(&info.terminal); + let settings = TerminalSettings::get_global(cx).clone(); + + let shell = Shell::WithArguments { + program: attach.program.to_string_lossy().into_owned(), + args: attach.args, + title_override: Some(info.title().to_owned()), + }; + let env: HashMap = attach.env.into_iter().collect(); + + TerminalBuilder::new( + info.cwd.clone(), + None, + shell, + env, + settings.cursor_shape, + settings.alternate_scroll, + settings.max_scroll_history_lines, + settings.path_hyperlink_regexes, + Duration::from_millis(settings.path_hyperlink_timeout_ms), + false, + window_id, + None, + cx, + Vec::new(), + PathStyle::local(), + ) + } + + pub fn from_builder( info: control::Session, - size: Size, - wakeups: mpsc::UnboundedSender, - ) -> zeddy_herdr::Result { - let attachment = client.attach(&info.id, geometry(size))?; - let (mut frames, input) = attachment.split(); - - let terminal = Arc::new(Mutex::new(Terminal::new(size))); - let ended = Arc::new(Mutex::new(None)); - let reader_wakeups = wakeups.clone(); - - std::thread::Builder::new() - .name(format!("zeddy-session-{}", info.id)) - .spawn({ - let terminal = terminal.clone(); - let ended = ended.clone(); - move || { - let outcome = loop { - match frames.next_frame() { - Ok(Some(frame)) => { - let mut terminal = terminal.lock().expect("terminal mutex"); - apply_frame(&mut terminal, &frame); - } - Ok(None) => break Ended::Closed, - Err(err) => break Ended::Failed(err.to_string()), - } - // Sent after the frame is applied, so a redraw woken by - // this always sees it. A closed receiver means the - // window is gone, and so is the reason to keep reading. - if reader_wakeups.unbounded_send(()).is_err() { - return; - } - }; - *ended.lock().expect("ended mutex") = Some(outcome); - let _ = reader_wakeups.unbounded_send(()); + builder: TerminalBuilder, + cx: &mut Context, + ) -> Self { + let terminal = cx.new(|cx| builder.subscribe(cx)); + let weak_terminal = terminal.downgrade(); + let (input_tx, mut input_rx) = mpsc::unbounded::>(); + let input_task = cx.spawn(async move |_, cx| { + while let Some(bytes) = input_rx.next().await { + if weak_terminal.update(cx, |terminal, _| terminal.input(bytes)).is_err() { + return; } - }) - .expect("spawn a session reader thread"); + } + }); - Ok(Self { - info, - terminal, - ended, - input: Arc::new(Mutex::new(input)), - client: client.clone(), - wakeups, - history_loading: Arc::new(AtomicBool::new(false)), - pending_scroll: Arc::new(Mutex::new(0)), - size, - }) + Self { info, terminal, ended: None, input_tx, _input_task: input_task } } pub fn id(&self) -> &PaneId { &self.info.id } - pub fn size(&self) -> Size { - self.size + pub fn terminal(&self) -> Entity { + self.terminal.clone() } - /// The screen as it stands. Cheap enough to call once per paint. - pub fn screen(&self) -> Screen { - self.terminal.lock().expect("terminal mutex").screen() + pub fn ended(&self) -> Option { + self.ended.clone() } - /// Whether the reader has stopped, and why. - pub fn ended(&self) -> Option { - self.ended.lock().expect("ended mutex").clone() + pub fn mark_ended(&mut self) { + self.ended = Some(Ended::Closed); } /// The live title inferred by the control plane: detected agent, foreground @@ -140,228 +111,28 @@ impl Session { self.info.title().to_owned() } - /// Send typed bytes to the session. - pub fn send(&mut self, bytes: &[u8]) -> zeddy_herdr::Result<()> { - self.input.lock().expect("session input mutex").send(bytes) - } - - /// Copy the active VT modes needed by the window-thread key encoder. - pub fn keyboard_modes(&self) -> KeyboardModes { - self.terminal.lock().expect("terminal mutex").keyboard_modes() - } - - /// Tell the session how many cells it now has. - /// - /// A no-op at the same size, because a resize costs a full repaint and the - /// window recomputes its cell count on every layout pass. The local grid is - /// resized before the command crosses the process boundary, so a divider - /// drag reflows on the next paint instead of waiting for herdr's repaint. - pub fn resize(&mut self, size: Size) -> zeddy_herdr::Result<()> { - if size == self.size { - return Ok(()); - } - self.size = size; - self.terminal.lock().expect("terminal mutex").resize(size); - self.input.lock().expect("session input mutex").resize(geometry(size)) - } - - /// Detach cleanly, leaving the session running for the next launch. - pub fn release(&mut self) { - let _ = self.input.lock().expect("session input mutex").release(); - } - pub fn access(&self) -> SessionAccess { - SessionAccess { - info: self.info.clone(), - input: self.input.clone(), - terminal: self.terminal.clone(), - client: self.client.clone(), - wakeups: self.wakeups.clone(), - history_loading: self.history_loading.clone(), - pending_scroll: self.pending_scroll.clone(), - } + SessionAccess { info: self.info.clone(), input_tx: self.input_tx.clone() } } } +/// Thread-safe capability passed to session-bound web plugins. #[derive(Clone)] pub struct SessionAccess { pub info: control::Session, - input: Arc>, - terminal: Arc>, - client: Client, - wakeups: mpsc::UnboundedSender, - history_loading: Arc, - pending_scroll: Arc>, + input_tx: mpsc::UnboundedSender>, } impl SessionAccess { pub fn send(&self, bytes: &[u8]) -> zeddy_herdr::Result<()> { - self.input.lock().expect("session input mutex").send(bytes) - } - - pub fn wheel(&self, event: WheelEvent) -> bool { - let mut terminal = self.terminal.lock().expect("terminal mutex"); - if let Some(bytes) = terminal.wheel_input_with_fallback(event, wheel_fallback(&self.info)) { - drop(terminal); - if !bytes.is_empty() { - let _ = self.input.lock().expect("session input mutex").send(&bytes); - } - return false; - } - - let result = terminal.scroll(event.lines); - drop(terminal); - match result { - ScrollResult::Changed => true, - ScrollResult::Unchanged => false, - ScrollResult::NeedsHistory => { - let mut pending = self.pending_scroll.lock().expect("pending scroll mutex"); - *pending = pending.saturating_add(event.lines); - drop(pending); - self.fetch_history(); - false - } - } - } - - fn fetch_history(&self) { - if self.history_loading.swap(true, Ordering::AcqRel) { - return; - } - let client = self.client.clone(); - let pane = self.info.id.clone(); - let terminal = self.terminal.clone(); - let loading = self.history_loading.clone(); - let loading_on_failure = self.history_loading.clone(); - let pending = self.pending_scroll.clone(); - let wakeups = self.wakeups.clone(); - let spawned = - std::thread::Builder::new().name(format!("zeddy-history-{pane}")).spawn(move || { - let requested_at = terminal.lock().expect("terminal mutex").generation(); - let history = client.history(&pane, HISTORY_LINES); - if let Ok(history) = history { - let mut terminal = terminal.lock().expect("terminal mutex"); - let lines = std::mem::take(&mut *pending.lock().expect("pending scroll mutex")); - terminal.load_history(&history, lines, requested_at); - } else { - *pending.lock().expect("pending scroll mutex") = 0; - } - loading.store(false, Ordering::Release); - let _ = wakeups.unbounded_send(()); - }); - if spawned.is_err() { - loading_on_failure.store(false, Ordering::Release); - } + self.input_tx + .unbounded_send(bytes.to_vec()) + .map_err(|_| zeddy_herdr::Error::Protocol("terminal is no longer available".to_owned())) } } -/// Herdr's repaint protocol currently omits mouse and alternate-screen modes. -/// Keep the workaround deliberately scoped to agents verified to use SGR -/// mouse input; ordinary foreground processes must retain host scrollback. -fn wheel_fallback(info: &control::Session) -> WheelFallback { - info.agent - .as_deref() - .into_iter() - .chain(info.running.as_deref()) - .any(is_mouse_aware_full_tui) - .then_some(WheelFallback::SgrMouse) - .unwrap_or(WheelFallback::Scrollback) -} - -fn is_mouse_aware_full_tui(name: &str) -> bool { - let compact: String = name.chars().filter(|character| character.is_alphanumeric()).collect(); - matches!(compact.to_ascii_lowercase().as_str(), "claude" | "claudecode" | "opencode" | "codex") -} - impl std::fmt::Debug for Session { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Session") - .field("id", &self.info.id) - .field("size", &self.size) - .finish_non_exhaustive() - } -} - -/// The two crates below zeddy each have their own name for a grid, and neither -/// should have to know about the other. These two functions are the seam. -fn geometry(size: Size) -> Geometry { - Geometry::new(size.cols, size.rows) -} - -fn size_of(geometry: Geometry) -> Size { - Size::new(geometry.cols, geometry.rows) -} - -/// Paint a frame only when it was produced for the grid the window currently -/// owns. -/// -/// Resizing the local emulator immediately leaves a small interval in which -/// herdr can still deliver frames queued for the previous geometry. Feeding -/// one of those into the new grid would wrap and position its contents against -/// the wrong width. The stream still consumes those frames to preserve its -/// sequence contract; herdr's full repaint for the current geometry resumes -/// painting. -fn apply_frame(terminal: &mut Terminal, frame: &Frame) -> bool { - if terminal.size() != size_of(frame.geometry) { - return false; - } - terminal.feed(&frame.bytes); - true -} - -#[cfg(test)] -mod tests { - use super::*; - - fn info(agent: Option<&str>, running: Option<&str>) -> control::Session { - control::Session { - id: PaneId("pane".to_owned()), - workspace: zeddy_herdr::WorkspaceId("workspace".to_owned()), - label: "shell".to_owned(), - running: running.map(str::to_owned), - status: control::SessionStatus::Unknown, - agent: agent.map(str::to_owned), - cwd: None, - } - } - - #[test] - fn the_two_grid_types_round_trip() { - assert_eq!(size_of(geometry(Size::new(120, 40))), Size::new(120, 40)); - } - - #[test] - fn only_verified_mouse_aware_full_tuis_use_the_mode_less_fallback() { - for name in ["Claude", "Claude Code", "OpenCode", "codex"] { - assert_eq!(wheel_fallback(&info(Some(name), None)), WheelFallback::SgrMouse); - assert_eq!(wheel_fallback(&info(None, Some(name))), WheelFallback::SgrMouse); - } - assert_eq!(wheel_fallback(&info(None, Some("npm run dev"))), WheelFallback::Scrollback,); - assert_eq!(wheel_fallback(&info(None, None)), WheelFallback::Scrollback); - } - - #[test] - fn a_frame_for_the_current_grid_is_applied() { - let mut terminal = Terminal::new(Size::new(120, 40)); - let frame = Frame { - bytes: b"current".to_vec(), - full: true, - seq: 1, - geometry: Geometry::new(120, 40), - }; - - assert!(apply_frame(&mut terminal, &frame)); - assert_eq!(terminal.screen().to_text().lines().next(), Some("current")); - } - - #[test] - fn a_queued_full_repaint_for_the_previous_grid_is_ignored() { - let mut terminal = Terminal::new(Size::new(120, 40)); - let frame = - Frame { bytes: b"stale".to_vec(), full: true, seq: 1, geometry: Geometry::new(80, 24) }; - - assert!(!apply_frame(&mut terminal, &frame)); - assert_eq!(terminal.screen().to_text().lines().next(), Some("")); - assert_eq!(terminal.size(), Size::new(120, 40)); + f.debug_struct("Session").field("id", &self.info.id).finish_non_exhaustive() } } diff --git a/crates/zeddy/src/settings.rs b/crates/zeddy/src/settings.rs index 3b675214..930a89c6 100644 --- a/crates/zeddy/src/settings.rs +++ b/crates/zeddy/src/settings.rs @@ -714,7 +714,7 @@ const SIDEBAR_THEME_PALETTES: [SidebarThemePalette; 15] = [ SidebarThemePalette::new("Catppuccin Macchiato", 0x242738, 0x2a2d40, 0x2c3043, 0x363a4f), SidebarThemePalette::new("Catppuccin Mocha", 0x1e1f2d, 0x252535, 0x272838, 0x313244), SidebarThemePalette::new("Gruvbox Dark", 0x3e3a38, 0x423d3b, 0x433e3c, 0x494340), - SidebarThemePalette::new("Gruvbox Light", 0xF0E6C9, 0xF0E6C9, 0xe3d3ac, 0xddcca7), + SidebarThemePalette::new("Gruvbox Light", 0xf0e6c9, 0xf0e6c9, 0xe3d3ac, 0xddcca7), SidebarThemePalette::new("One Dark", 0x313640, 0x333842, 0x333943, 0x363c46), SidebarThemePalette::new("One Light", 0xe8e8e9, 0xe5e5e6, 0xe4e4e5, 0xdfdfe0), SidebarThemePalette::new("VSCode Dark Modern", 0x1d1d1d, 0x222222, 0x232323, 0x2b2b2b), @@ -1005,11 +1005,6 @@ mod tests { let registered = registry.get(palette.name).unwrap(); let colors = sidebar_theme_colors(®istered); assert_eq!(colors, palette.colors()); - assert_ne!( - colors.card_inactive, colors.card_active, - "{} needs distinct inactive and active cards", - palette.name, - ); assert_ne!( colors.card_active, colors.session_active, "{} needs a visible selected session inside an active card", diff --git a/crates/zeddy/src/settings_window.rs b/crates/zeddy/src/settings_window.rs index 8f735354..74e7bc5a 100644 --- a/crates/zeddy/src/settings_window.rs +++ b/crates/zeddy/src/settings_window.rs @@ -21,7 +21,7 @@ use crate::{ components::{ ContextMenu, SegmentedControl, SegmentedControlOption, selection_list, selection_row, }, - fonts::{Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, + fonts::{self, Fonts, UI_LABEL_DEFAULT, UI_LABEL_LARGE, UI_LABEL_SMALL, UI_TEXT_DEFAULT}, keymap::{KeymapAction, KeymapStore}, mode::Mode, persistence::SidebarScope, @@ -32,7 +32,7 @@ use crate::{ text_input::{InputEvent, TextInput}, }; -actions!(settings_window, [Close]); +actions!(chartr_settings_window, [Close]); const SETTINGS_WINDOW_MIN_WIDTH: f32 = 720.; const SETTINGS_CONTROL_COLUMN_WIDTH: f32 = 200.; @@ -227,10 +227,7 @@ impl SettingsWindow { settings::apply_theme(&resolved, cx); } if apply_fonts { - theme::set_theme_settings_provider( - Box::new(Fonts::from_settings(&resolved)), - cx, - ); + fonts::install(&resolved, cx); } self.problem = None; } @@ -342,7 +339,7 @@ impl SettingsWindow { Some(family); }, false, - false, + true, cx, ); } @@ -358,7 +355,7 @@ impl SettingsWindow { Some(size); }, false, - false, + true, cx, ); } @@ -1461,12 +1458,10 @@ mod tests { fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { + ::settings::init(cx); theme::init(theme::LoadThemes::JustBase, cx); let settings = SettingsStore::bare(); - theme::set_theme_settings_provider( - Box::new(Fonts::from_settings(settings.resolved())), - cx, - ); + fonts::install(settings.resolved(), cx); cx.set_global(settings); let keymap = KeymapStore::bare(); init(&keymap, cx); @@ -1567,6 +1562,14 @@ mod tests { let resolved = cx.global::().resolved(); assert_eq!(resolved.ui_font_size, 18.); assert_eq!(resolved.terminal_font_size, 16.); + assert_eq!(theme::theme_settings(cx).buffer_font_size(cx), px(16.)); + assert_eq!( + ::get_global( + cx, + ) + .font_size, + Some(px(16.)) + ); }); } } diff --git a/crates/zeddy/src/space.rs b/crates/zeddy/src/space.rs index dbcd3e99..f38e9b60 100644 --- a/crates/zeddy/src/space.rs +++ b/crates/zeddy/src/space.rs @@ -10,8 +10,7 @@ use std::{ path::PathBuf, }; -use futures::{StreamExt as _, channel::mpsc}; -use gpui::{Context, Task}; +use gpui::{Context, EventEmitter}; use zeddy_herdr::{PaneId, WorkspaceId, control::Client}; use crate::{ @@ -29,6 +28,14 @@ pub enum Kind { Registered, } +/// Window-owned integrations are created in response to these events. A +/// `Space` can finish attaching a session without access to a GPUI window; +/// emitting the stable item id keeps that asynchronous model boundary clean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpaceEvent { + TerminalReady(ItemId), +} + pub struct Space { name: String, path: PathBuf, @@ -45,8 +52,6 @@ pub struct Space { restoring_plugins: Vec, drag_target: Option<(WorkspaceTabId, crate::workspace::PaneId, Option)>, problem: Option, - wakeup_tx: mpsc::UnboundedSender<()>, - _wakeups: Task<()>, } impl Space { @@ -55,9 +60,8 @@ impl Space { path: PathBuf, kind: Kind, client: Client, - cx: &mut Context, + _cx: &mut Context, ) -> Self { - let (wakeup_tx, wakeup_rx) = mpsc::unbounded(); Self { name, path, @@ -74,22 +78,9 @@ impl Space { restoring_plugins: Vec::new(), drag_target: None, problem: None, - wakeup_tx, - _wakeups: Self::watch(wakeup_rx, cx), } } - fn watch(mut wakeups: mpsc::UnboundedReceiver<()>, cx: &mut Context) -> Task<()> { - cx.spawn(async move |this, cx| { - while wakeups.next().await.is_some() { - while wakeups.try_recv().is_ok() {} - if this.update(cx, |_, cx| cx.notify()).is_err() { - return; - } - } - }) - } - pub fn name(&self) -> &str { &self.name } @@ -139,6 +130,23 @@ impl Space { self.items.get(&id) } + pub fn install_terminal_view( + &mut self, + id: ItemId, + view: gpui::Entity, + ) { + if let Some(item) = self.items.get_mut(&id).and_then(Item::as_session_mut) { + item.install_terminal_view(view); + } + } + + pub fn set_terminal_bell(&mut self, id: ItemId, bell: bool) -> bool { + self.items + .get_mut(&id) + .and_then(Item::as_session_mut) + .is_some_and(|item| item.set_bell(bell)) + } + pub fn reattaching(&self, id: ItemId) -> bool { self.reattaching.contains(&id) } @@ -155,6 +163,37 @@ impl Space { .map(|item| item.session.id().clone()) } + fn session_from_builder( + &mut self, + info: zeddy_herdr::control::Session, + builder: terminal::TerminalBuilder, + cx: &mut Context, + ) -> Session { + let backend_id = info.id.clone(); + let session = Session::from_builder(info, builder, cx); + let terminal = session.terminal(); + let terminal_entity = terminal.entity_id(); + cx.subscribe(&terminal, move |this, _, event, cx| { + if !matches!(event, terminal::Event::CloseTerminal) { + return; + } + let Some(item_id) = this.sessions.get(&backend_id).copied() else { + return; + }; + let Some(item) = this.items.get_mut(&item_id).and_then(Item::as_session_mut) else { + return; + }; + // A reattach uses `--takeover`, which closes the superseded local + // client. Do not let that old client's exit mark the replacement. + if item.session.terminal().entity_id() == terminal_entity { + item.session.mark_ended(); + cx.notify(); + } + }) + .detach(); + session + } + pub fn reattach(&mut self, id: ItemId, cx: &mut Context) { if !self.reattaching.insert(id) { return; @@ -163,34 +202,21 @@ impl Space { self.reattaching.remove(&id); return; }; - let backend_id = session.session.id().clone(); - let size = session.session.size(); + let info = session.session.info.clone(); let client = self.client.clone(); - let wakeups = self.wakeup_tx.clone(); - let executor = cx.background_executor().clone(); + let attach = Session::attach_builder(&client, &info, cx.entity_id().as_u64(), cx); cx.spawn(async move |this, cx| { - let result = executor - .spawn(async move { - let info = client - .sessions(None)? - .into_iter() - .find(|info| info.id == backend_id) - .ok_or_else(|| { - zeddy_herdr::Error::Protocol(format!( - "Herdr no longer reports session {}", - backend_id.0 - )) - })?; - Session::attach(&client, info, size, wakeups) - }) - .await; + let result = attach.await; let _ = this.update(cx, |this, cx| { this.reattaching.remove(&id); match result { - Ok(session) => { + Ok(builder) => { + let session = this.session_from_builder(info, builder, cx); if let Some(item) = this.items.get_mut(&id).and_then(Item::as_session_mut) { item.session = session; + item.clear_terminal_view(); this.problem = None; + cx.emit(SpaceEvent::TerminalReady(id)); } } Err(error) => this.problem = Some(error.to_string()), @@ -464,6 +490,7 @@ impl Space { status: (!grouped).then(|| item.status()).flatten(), process_running: !grouped && item.process_running(), ended: !grouped && item.ended(), + bell: !grouped && item.as_session().is_some_and(SessionItem::bell), selected: self.layout.active_tab_id() == Some(tab.id), closable: true, grouped, @@ -545,7 +572,6 @@ impl Space { if let Err(error) = self.layout.activate_item(item) { self.problem = Some(error.to_string()); } - self.fit_items(); } Action::Close { item, .. } => self.close_item(item, cx), Action::New @@ -673,40 +699,8 @@ impl Space { } } - pub fn send_active(&mut self, bytes: &[u8], cx: &mut Context) { - let Some(id) = self.active() else { - return; - }; - if let Some(session) = self.items.get_mut(&id).and_then(Item::as_session_mut) - && let Err(error) = session.session.send(bytes) - { - self.problem = Some(error.to_string()); - cx.notify(); - } - } - - /// Keyboard mode state for the active terminal, or `None` for a plugin. - pub fn active_keyboard_modes(&self) -> Option { - let id = self.active()?; - self.items.get(&id)?.as_session().map(|item| item.session.keyboard_modes()) - } - - pub fn fit_items(&mut self) { - for item in self.items.values_mut() { - let Some(item) = item.as_session_mut() else { - continue; - }; - let Some(size) = item.fit.get() else { - continue; - }; - if let Err(error) = item.session.resize(size) { - self.problem = Some(error.to_string()); - } - } - } - /// Attach sessions discovered by the parent's one backend snapshot. - /// Process spawning and stream setup stay off the frame thread. + /// Local PTY creation stays off the frame thread. pub fn adopt(&mut self, infos: Vec, cx: &mut Context) { let mut discovered = Vec::new(); for info in infos { @@ -723,10 +717,6 @@ impl Space { return; } - let client = self.client.clone(); - let wakeups = self.wakeup_tx.clone(); - let size = zeddy_vt::Size::default(); - let executor = cx.background_executor().clone(); let restored_ids: HashMap<_, _> = infos .iter() .filter_map(|info| { @@ -737,22 +727,30 @@ impl Space { for item in stale { let _ = self.layout.remove_item(item); } + let client = self.client.clone(); + let window_id = cx.entity_id().as_u64(); + let pending = infos + .into_iter() + .map(|info| { + let restored = restored_ids.get(&info.id).copied(); + let attach = Session::attach_builder(&client, &info, window_id, cx); + (restored, info, attach) + }) + .collect::>(); cx.spawn(async move |this, cx| { - let attached = executor - .spawn(async move { - infos - .into_iter() - .map(|info| { - let restored = restored_ids.get(&info.id).copied(); - (restored, Session::attach(&client, info, size, wakeups.clone())) - }) - .collect::>() - }) - .await; + let mut attached = Vec::with_capacity(pending.len()); + for (restored, info, attach) in pending { + attached.push((restored, info, attach.await)); + } let _ = this.update(cx, |this, cx| { - for (restored, result) in attached { + for (restored, info, result) in attached { match result { - Ok(session) => this.insert_session_with_id(session, restored), + Ok(builder) => { + let session = this.session_from_builder(info, builder, cx); + if let Some(id) = this.insert_session_with_id(session, restored) { + cx.emit(SpaceEvent::TerminalReady(id)); + } + } Err(error) => { if let Some(item) = restored { let _ = this.layout.remove_item(item); @@ -804,27 +802,49 @@ impl Space { let workspace = self.workspace.clone(); let path = self.path.clone(); let label = self.name.clone(); - let wakeups = self.wakeup_tx.clone(); - let size = zeddy_vt::Size::default(); + let create_client = client.clone(); let executor = cx.background_executor().clone(); + let window_id = cx.entity_id().as_u64(); cx.spawn(async move |this, cx| { - let result = executor + let info = executor .spawn(async move { let info = match workspace { - Some(workspace) => client.start_session(&workspace, None), - None => client.create_workspace(&path, Some(&label)), + Some(workspace) => create_client.start_session(&workspace, None), + None => create_client.create_workspace(&path, Some(&label)), }?; - Session::attach(&client, info, size, wakeups) + zeddy_herdr::Result::Ok(info) }) .await; + let info = match info { + Ok(info) => info, + Err(error) => { + let _ = this.update(cx, |this, cx| { + this.starting = false; + this.problem = Some(error.to_string()); + cx.notify(); + }); + return; + } + }; + let Ok(attach) = + this.update(cx, |_, cx| Session::attach_builder(&client, &info, window_id, cx)) + else { + return; + }; + let result = attach.await; let _ = this.update(cx, |this, cx| { this.starting = false; match result { - Ok(session) => { + Ok(builder) => { + let session = this.session_from_builder(info, builder, cx); if let Some((tab, pane)) = destination { - this.insert_session_in(session, tab, pane); + if let Some(id) = this.insert_session_in(session, tab, pane) { + cx.emit(SpaceEvent::TerminalReady(id)); + } } else { - this.insert_session(session); + if let Some(id) = this.insert_session(session) { + cx.emit(SpaceEvent::TerminalReady(id)); + } } this.problem = None; } @@ -836,8 +856,8 @@ impl Space { .detach(); } - fn insert_session(&mut self, session: Session) { - self.insert_session_with_id(session, None); + fn insert_session(&mut self, session: Session) -> Option { + self.insert_session_with_id(session, None) } fn insert_session_in( @@ -845,11 +865,11 @@ impl Space { session: Session, tab: WorkspaceTabId, pane: crate::workspace::PaneId, - ) { + ) -> Option { self.workspace = Some(session.info.workspace.clone()); let backend_id = session.id().clone(); if self.sessions.contains_key(&backend_id) { - return; + return None; } let id = self.layout.alloc_item(); @@ -864,16 +884,21 @@ impl Space { } else if let Err(error) = self.layout.push_standalone(id) { self.items.remove(&id); self.problem = Some(error.to_string()); - return; + return None; } self.sessions.insert(backend_id, id); + Some(id) } - fn insert_session_with_id(&mut self, session: Session, restored: Option) { + fn insert_session_with_id( + &mut self, + session: Session, + restored: Option, + ) -> Option { self.workspace = Some(session.info.workspace.clone()); let backend_id = session.id().clone(); if self.sessions.contains_key(&backend_id) { - return; + return None; } let id = restored.unwrap_or_else(|| self.layout.alloc_item()); self.items.insert(id, Item::Session(SessionItem::new(session))); @@ -882,9 +907,10 @@ impl Space { { self.items.remove(&id); self.problem = Some(error.to_string()); - return; + return None; } self.sessions.insert(backend_id, id); + Some(id) } fn close_item(&mut self, id: ItemId, cx: &mut Context) { @@ -917,13 +943,12 @@ impl Space { } fn remove_item(&mut self, id: ItemId) { - let Some(mut item) = self.items.remove(&id) else { + let Some(item) = self.items.remove(&id) else { return; }; - if let Some(session) = item.as_session_mut() { + if let Some(session) = item.as_session() { let backend_id = session.session.id().clone(); self.sessions.remove(&backend_id); - session.session.release(); let dependents: Vec<_> = self .items .iter() @@ -965,15 +990,7 @@ impl Space { } } -impl Drop for Space { - fn drop(&mut self) { - for item in self.items.values_mut() { - if let Some(item) = item.as_session_mut() { - item.session.release(); - } - } - } -} +impl EventEmitter for Space {} pub fn name_for(kind: Kind, path: &std::path::Path) -> String { match kind { diff --git a/crates/zeddy/src/terminal.rs b/crates/zeddy/src/terminal.rs deleted file mode 100644 index 78347c60..00000000 --- a/crates/zeddy/src/terminal.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! Painting a [`Screen`]. -//! -//! This is a custom [`Element`] rather than a tree of styled `div`s. A terminal -//! is a grid of thousands of cells that changes many times a second, and a div -//! per cell would put a Taffy layout node per cell on the frame path. Here the -//! whole screen is one element: one shaped line per row, and the runs inside it -//! carry the colours. -//! -//! # The grid is measured here and used elsewhere -//! -//! How many cells fit is a question only the paint pass can answer — it depends -//! on the font metrics and on the bounds the layout gave us. But the *answer* -//! belongs to the session, which has to tell herdr about it. So the element -//! writes the measured grid into a shared [`Fit`] and the view reads it. A -//! changed fit explicitly schedules that follow-up frame: pane-tree edits such -//! as splits and tab moves are one-shot events, so there may be no mouse event -//! or terminal repaint to schedule it for us. - -use std::{cell::Cell as StdCell, rc::Rc}; - -use gpui::{ - App, Bounds, Element, ElementId, Font, FontWeight, GlobalElementId, Hsla, InspectorElementId, - IntoElement, LayoutId, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, - Window, fill, point, px, size, -}; -use zeddy_vt::{CellPosition, Screen, Size}; - -/// The grid the last paint found room for. -/// -/// Shared between the element that measures it and the view that acts on it. -/// A plain `Cell` because both ends are on the window thread. -/// -/// Read rather than consumed: the view checks it on every frame and the session -/// ignores a size it is already running at, so a steady window costs one -/// comparison per frame and a dragged one costs a resize per frame. -#[derive(Debug, Clone, Default)] -pub struct Fit { - size: Rc>>, - line_height: Rc>>, - bounds: Rc>>>, - cell_width: Rc>>, - scroll_px: Rc>, -} - -impl Fit { - pub fn get(&self) -> Option { - self.size.get() - } - - fn set(&self, size: Size) -> bool { - self.size.replace(Some(size)) != Some(size) - } - - fn measure( - &self, - size: Size, - bounds: Bounds, - cell_width: Pixels, - line_height: Pixels, - ) -> bool { - self.bounds.set(Some(bounds)); - self.cell_width.set(Some(cell_width)); - self.line_height.set(Some(line_height)); - self.set(size) - } - - /// Resolve a window-space pointer position to the nearest visible cell. - pub fn cell_at(&self, position: Point) -> Option { - let bounds = self.bounds.get()?; - let cell_width = self.cell_width.get()?; - let line_height = self.line_height.get()?; - let size = self.size.get()?; - let local = position - bounds.origin; - let col = (local.x / cell_width).floor().clamp(0., f32::from(size.cols - 1)) as u16; - let row = (local.y / line_height).floor().clamp(0., f32::from(size.rows - 1)) as u16; - Some(CellPosition::new(col, row)) - } - - /// Quantize a wheel or trackpad gesture into terminal lines. - /// - /// Pixel deltas accumulate until they cross a full row, while traditional - /// mouse-wheel line deltas pass through exactly. - pub fn wheel_lines(&self, event: &gpui::ScrollWheelEvent) -> Option { - let line_height = self.line_height.get()?; - match event.touch_phase { - gpui::TouchPhase::Started => { - self.scroll_px.set(0.); - } - gpui::TouchPhase::Ended | gpui::TouchPhase::Cancelled => return None, - gpui::TouchPhase::Moved => {} - } - - let line_height = line_height / px(1.); - let accumulated = - self.scroll_px.get() + event.delta.pixel_delta(px(line_height)).y / px(1.); - let lines = (accumulated / line_height).trunc() as i32; - self.scroll_px.set(accumulated - lines as f32 * line_height); - (lines != 0).then_some(lines) - } -} - -/// How a terminal is drawn: the font, and the colours a cell's `Default` means. -#[derive(Debug, Clone)] -pub struct Appearance { - pub font: Font, - pub font_size: Pixels, - pub line_height: Pixels, - pub background: Hsla, - pub cursor: Hsla, -} - -/// One screen, painted. -pub struct TerminalElement { - screen: Screen, - appearance: Appearance, - /// A blurred terminal draws a hollow cursor, the way every native terminal - /// does — it is how you tell at a glance which pane has the keyboard. - focused: bool, - fit: Fit, - /// Resolved by the caller, because only it has the theme. - colors: Vec>, -} - -impl TerminalElement { - pub fn new( - screen: Screen, - colors: Vec>, - appearance: Appearance, - focused: bool, - fit: Fit, - ) -> Self { - Self { screen, appearance, focused, fit, colors } - } -} - -/// What [`Element::prepaint`] worked out and [`Element::paint`] needs. -pub struct Metrics { - cell: gpui::Size, -} - -impl IntoElement for TerminalElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for TerminalElement { - type RequestLayoutState = (); - type PrepaintState = Metrics; - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, ()) { - // Full width, and *grown* into the remaining height rather than sized - // at 100% of it: a percentage height against a parent whose own height - // comes from a flex line resolves to zero, and a terminal one cell tall - // is not an obvious-looking bug — it looks like a terminal that will not - // scroll. The parent is a column, so growing is what fills it. - let style = Style { - flex_grow: 1., - size: size(gpui::relative(1.).into(), gpui::Length::Auto), - ..Style::default() - }; - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: Bounds, - _: &mut (), - window: &mut Window, - cx: &mut App, - ) -> Metrics { - // The font is monospace, so one glyph's advance is every glyph's. - let em = window - .text_system() - .shape_line( - SharedString::from("M"), - self.appearance.font_size, - &[Look { - fg: gpui::black(), - bg: None, - bold: false, - italic: false, - underline: false, - } - .run(1, &self.appearance)], - None, - ) - .width - .max(px(1.)); - let cell = size(em, self.appearance.line_height); - - let measured = Size::new( - (bounds.size.width / cell.width).floor() as u16, - (bounds.size.height / cell.height).floor() as u16, - ); - let fit_changed = self.fit.measure(measured, bounds, cell.width, cell.height); - if fit_changed { - // `Window::refresh` is intentionally ignored while GPUI is in a - // draw pass. Defer it until the pass completes so the next render - // can apply this fit before taking the terminal screen snapshot. - window.defer(cx, |window, _| window.refresh()); - } - - Metrics { cell } - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - bounds: Bounds, - _: &mut (), - metrics: &mut Metrics, - window: &mut Window, - cx: &mut App, - ) { - window.paint_quad(fill(bounds, self.appearance.background)); - - for (index, row) in self.screen.rows.iter().enumerate() { - let origin = bounds.origin + point(px(0.), metrics.cell.height * index as f32); - if origin.y > bounds.bottom() { - break; - } - - let colors = &self.colors[index]; - let text: String = row.iter().map(|cell| cell.ch).collect(); - - // One run per cell would shape every glyph separately; merging - // neighbours that look alike is what makes a line of plain text one - // run instead of eighty. - let mut runs: Vec = Vec::new(); - let mut last: Option = None; - for (cell, &(fg, bg)) in row.iter().zip(colors) { - let look = Look { - fg, - bg: (bg != self.appearance.background).then_some(bg), - bold: cell.style.bold, - italic: cell.style.italic, - underline: cell.style.underline, - }; - match (&last, runs.last_mut()) { - (Some(previous), Some(run)) if *previous == look => { - run.len += cell.ch.len_utf8() - } - _ => { - runs.push(look.run(cell.ch.len_utf8(), &self.appearance)); - last = Some(look); - } - } - } - - let line = window.text_system().shape_line( - SharedString::from(text), - self.appearance.font_size, - &runs, - None, - ); - let _ = line.paint_background( - origin, - metrics.cell.height, - TextAlign::Left, - None, - window, - cx, - ); - let _ = line.paint(origin, metrics.cell.height, TextAlign::Left, None, window, cx); - } - - if let Some(cursor) = self.screen.cursor { - let origin = bounds.origin - + point( - metrics.cell.width * cursor.col as f32, - metrics.cell.height * cursor.row as f32, - ); - let cell = Bounds { origin, size: metrics.cell }; - if self.focused { - window.paint_quad(fill(cell, self.appearance.cursor)); - } else { - let mut hollow = - gpui::outline(cell, self.appearance.cursor, gpui::BorderStyle::Solid); - hollow.border_widths = px(1.).into(); - window.paint_quad(hollow); - } - } - } -} - -/// Everything about a cell that decides which run it belongs to. -/// -/// Two adjacent cells share a run exactly when their `Look`s are equal, which -/// is a single comparison rather than a rule spread across five fields. -#[derive(Debug, Clone, Copy, PartialEq)] -struct Look { - fg: Hsla, - bg: Option, - bold: bool, - italic: bool, - underline: bool, -} - -impl Look { - fn run(&self, len: usize, appearance: &Appearance) -> TextRun { - TextRun { - len, - font: Font { - weight: if self.bold { FontWeight::BOLD } else { appearance.font.weight }, - style: if self.italic { gpui::FontStyle::Italic } else { appearance.font.style }, - ..appearance.font.clone() - }, - color: self.fg, - background_color: self.bg, - underline: self.underline.then(|| UnderlineStyle { - color: Some(self.fg), - thickness: px(1.), - wavy: false, - }), - strikethrough: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fit_reports_only_real_grid_changes() { - let fit = Fit::default(); - assert!(fit.set(Size::new(80, 24))); - assert!(!fit.set(Size::new(80, 24))); - assert!(fit.set(Size::new(120, 40))); - assert_eq!(fit.get(), Some(Size::new(120, 40))); - } - - #[test] - fn wheel_deltas_are_measured_in_terminal_lines() { - let fit = Fit::default(); - fit.measure( - Size::new(80, 24), - Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), - px(10.), - px(20.), - ); - let event = gpui::ScrollWheelEvent { - delta: gpui::ScrollDelta::Lines(point(0., 2.)), - ..Default::default() - }; - - assert_eq!(fit.wheel_lines(&event), Some(2)); - } - - #[test] - fn trackpad_pixels_accumulate_to_complete_rows() { - let fit = Fit::default(); - fit.measure( - Size::new(80, 24), - Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), - px(10.), - px(20.), - ); - let event = |pixels| gpui::ScrollWheelEvent { - delta: gpui::ScrollDelta::Pixels(point(px(0.), px(pixels))), - ..Default::default() - }; - - assert_eq!(fit.wheel_lines(&event(9.)), None); - assert_eq!(fit.wheel_lines(&event(11.)), Some(1)); - } - - #[test] - fn a_trackpad_gestures_first_delta_is_not_dropped() { - let fit = Fit::default(); - fit.measure( - Size::new(80, 24), - Bounds::new(point(px(0.), px(0.)), size(px(800.), px(480.))), - px(10.), - px(20.), - ); - let event = gpui::ScrollWheelEvent { - delta: gpui::ScrollDelta::Pixels(point(px(0.), px(20.))), - touch_phase: gpui::TouchPhase::Started, - ..Default::default() - }; - - assert_eq!(fit.wheel_lines(&event), Some(1)); - } - - #[test] - fn pointer_positions_resolve_to_bounded_terminal_cells() { - let fit = Fit::default(); - fit.measure( - Size::new(80, 24), - Bounds::new(point(px(10.), px(20.)), size(px(800.), px(480.))), - px(10.), - px(20.), - ); - - assert_eq!(fit.cell_at(point(px(35.), px(65.))), Some(CellPosition::new(2, 2))); - assert_eq!(fit.cell_at(point(px(0.), px(0.))), Some(CellPosition::new(0, 0))); - assert_eq!(fit.cell_at(point(px(900.), px(600.))), Some(CellPosition::new(79, 23))); - } -} diff --git a/crates/zeddy/src/terminal_host.rs b/crates/zeddy/src/terminal_host.rs new file mode 100644 index 00000000..9c699ec3 --- /dev/null +++ b/crates/zeddy/src/terminal_host.rs @@ -0,0 +1,156 @@ +//! The single adapter between Chartr's workspace model and Zed's terminal UI. +//! +//! `TerminalView` intentionally supports non-workspace hosts, but its public +//! constructor still accepts weak Zed `Workspace` and `Project` handles for +//! optional integrations such as pane actions and assistant context. Chartr +//! owns neither type. Invalid weak handles express that absence without +//! manufacturing a partial Zed workspace; disabling workspace actions selects +//! the view's documented non-workspace-host path. Chartr then selects the one +//! maintained host extension, top grid alignment; all terminal behavior remains +//! Zed's pinned model and view. + +use gpui::{ + App, AppContext as _, Div, Entity, Hsla, InteractiveElement as _, ParentElement as _, + Styled as _, WeakEntity, Window, div, +}; + +/// Host an existing Zed terminal model in Chartr's pane tree. +pub fn new_view( + terminal: Entity, + window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|cx| { + let mut view = terminal_view::TerminalView::new( + terminal, + WeakEntity::new_invalid(), + None, + WeakEntity::new_invalid(), + window, + cx, + ); + view.set_show_workspace_actions(false, cx); + view.set_vertical_alignment(terminal_view::TerminalVerticalAlignment::Top, cx); + view + }) +} + +/// Mount a TerminalView exactly as Zed mounts it: it fills the available pane +/// without an additional product-level inset, and the TerminalElement remains +/// the innermost mouse target. The view itself owns its one-cell grid gutter. +pub fn element(view: Entity, background: Hsla) -> Div { + let drop_view = view.clone(); + div() + .size_full() + .bg(background) + .can_drop(|value, _, _| value.downcast_ref::().is_some()) + .on_drop(move |paths: &gpui::ExternalPaths, window, cx| { + drop_view.update(cx, |view, cx| view.add_paths_to_terminal(paths.paths(), window, cx)); + }) + .child(view) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + FocusHandle, Focusable as _, Modifiers, Render, TestAppContext, point, px, size, + transparent_black, + }; + use terminal::{ + TerminalBuilder, + terminal_settings::{AlternateScroll, CursorShape}, + }; + use util::paths::PathStyle; + + struct TestHost { + terminal: Entity, + view: Entity, + other_focus: FocusHandle, + } + + impl Render for TestHost { + fn render( + &mut self, + _: &mut Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::div() + .size_full() + .track_focus(&self.other_focus) + .child(element(self.view.clone(), transparent_black())) + } + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + ::settings::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + crate::fonts::install(&crate::settings::ResolvedSettings::default(), cx); + }); + } + + #[gpui::test] + fn mounts_zeds_terminal_view_as_a_stable_chartr_view(cx: &mut TestAppContext) { + init_test(cx); + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only( + CursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + let terminal_for_view = terminal.clone(); + let terminal_for_host = terminal.clone(); + let (host, cx) = cx.add_window_view(|window, cx| TestHost { + terminal: terminal_for_host, + view: new_view(terminal_for_view, window, cx), + other_focus: cx.focus_handle(), + }); + let view = host.read_with(cx, |host, _| host.view.clone()); + + host.update_in(cx, |host, window, cx| { + window.focus(&host.other_focus, cx); + }); + terminal.update(cx, |terminal, cx| terminal.write_output(b"\x1b[?1049hhello", cx)); + cx.simulate_resize(size(px(400.), px(201.))); + cx.run_until_parked(); + + cx.simulate_click(point(px(100.), px(100.)), Modifiers::none()); + view.update_in(cx, |view, window, cx| { + assert!(view.focus_handle(cx).is_focused(window)); + }); + + assert!(terminal.read_with(cx, |terminal, _| terminal.used_lines()) >= 1); + let initial_line_height = terminal.read_with(cx, |terminal, _| { + let bounds = terminal.last_content().terminal_bounds; + assert_eq!(bounds.bounds.origin.y, px(0.)); + assert!(bounds.bounds.origin.x > px(0.)); + assert!(bounds.bounds.origin.x <= bounds.cell_width); + bounds.line_height + }); + + cx.simulate_resize(size(px(400.), px(202.))); + cx.run_until_parked(); + assert_eq!( + terminal.read_with(cx, |terminal, _| { + terminal.last_content().terminal_bounds.bounds.origin.y + }), + px(0.) + ); + + let mut larger_typography = crate::settings::ResolvedSettings::default(); + larger_typography.terminal_font_size = 19.; + cx.update(|_, cx| crate::fonts::install(&larger_typography, cx)); + cx.run_until_parked(); + let larger_line_height = terminal + .read_with(cx, |terminal, _| terminal.last_content().terminal_bounds.line_height); + assert!(larger_line_height > initial_line_height); + + assert_eq!(host.read_with(cx, |host, _| host.terminal.entity_id()), terminal.entity_id()); + } +} diff --git a/crates/zeddy/src/text_input.rs b/crates/zeddy/src/text_input.rs index c6d542c0..b24f7c5d 100644 --- a/crates/zeddy/src/text_input.rs +++ b/crates/zeddy/src/text_input.rs @@ -19,7 +19,7 @@ use ui::prelude::*; use unicode_segmentation::UnicodeSegmentation as _; actions!( - native_text_input, + chartr_text_input, [ Backspace, Delete, diff --git a/crates/zeddy/tests/live_session.rs b/crates/zeddy/tests/live_session.rs index a98e0223..a080461e 100644 --- a/crates/zeddy/tests/live_session.rs +++ b/crates/zeddy/tests/live_session.rs @@ -1,19 +1,16 @@ -//! Smoke tests against a real herdr daemon. +//! Smoke tests against a real Herdr daemon. //! -//! Ignored by default: they start zeddy's private backend, run a shell in it, -//! and are therefore neither hermetic nor fast. Run them when the herdr pin -//! moves, which is the moment the CLI coupling in `zeddy-herdr::stream` can -//! break without any unit test noticing. +//! Ignored by default: they start Chartr's private backend. Run them when the +//! Herdr pin moves, which is when the direct-attach CLI contract can change. //! //! cargo test -p zeddy --test live_session -- --ignored --nocapture use std::{ process::{Child, Command, Stdio}, - time::{Duration, Instant}, + time::Duration, }; -use zeddy_herdr::{Geometry, Namespace, Sidecar, control::Client}; -use zeddy_vt::{CellPosition, Modifiers, Size, Terminal, WheelEvent, WheelFallback}; +use zeddy_herdr::{Namespace, Sidecar, control::Client}; struct Live { client: Client, @@ -69,168 +66,34 @@ fn sidecar() -> Sidecar { #[test] #[ignore = "needs a real herdr daemon"] -fn a_shell_paints_something_within_a_few_seconds() { +fn a_created_session_has_a_native_direct_attach_target() { let live = Live::start(); - let client = &live.client; - - let cwd = std::env::temp_dir(); - let workspace = client.open_workspace(&cwd, Some("zeddy-live")).expect("a workspace"); - let session = client.start_session(&workspace, None).expect("a session"); - println!("session {} in {workspace}", session.id); - - let size = Size::new(80, 24); - let attachment = - client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); - let (mut frames, mut input) = attachment.split(); - - let mut terminal = Terminal::new(size); - input.send(b"echo zeddy-live-marker\r").expect("send"); - - let deadline = Instant::now() + Duration::from_secs(15); - let mut seen = 0; - while Instant::now() < deadline { - match frames.next_frame().expect("the stream stays valid") { - Some(frame) => { - seen += 1; - println!( - "frame {} full={} {}x{} {} bytes", - frame.seq, - frame.full, - frame.geometry.cols, - frame.geometry.rows, - frame.bytes.len() - ); - if frame.full { - terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); - } - terminal.feed(&frame.bytes); - if terminal.screen().to_text().contains("zeddy-live-marker") { - break; - } - } - None => panic!("the stream closed after {seen} frames"), - } - } - - let text = terminal.screen().to_text(); - println!("--- screen ---\n{text}\n--- end ---"); - let _ = client.close_session(&session.id); - - assert!(seen > 0, "no frames arrived at all"); - assert!(text.contains("zeddy-live-marker"), "the echo never reached the screen"); -} - -#[test] -#[ignore = "needs a real herdr daemon"] -fn scrollback_survives_the_real_frame_stream() { - let live = Live::start(); - let client = &live.client; let workspace = - client.open_workspace(&std::env::temp_dir(), Some("zeddy-modes")).expect("a workspace"); - let session = client.start_session(&workspace, None).expect("a session"); - let size = Size::new(80, 24); - let attachment = - client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); - let (mut frames, mut input) = attachment.split(); - let mut terminal = Terminal::new(size); - - input - .send(b"i=1; while [ $i -le 40 ]; do printf 'scroll-%02d\\r\\n' $i; i=$((i+1)); done\r") - .expect("send scrolling output"); - - for _ in 0..20 { - let frame = frames.next_frame().expect("the stream stays valid").expect("a frame"); - if frame.full { - terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); - } - terminal.feed(&frame.bytes); - if terminal.screen().to_text().contains("scroll-40") { - break; - } - } - - let live_screen = terminal.screen().to_text(); - assert!(live_screen.contains("scroll-40"), "the command did not finish painting"); - let requested_at = terminal.generation(); - let ansi = client.history(&session.id, 10_000).expect("read host scrollback"); - assert!( - terminal.load_history(&ansi, i32::MAX, requested_at), - "host scrollback did not move the viewport" - ); - let history = terminal.screen().to_text(); - let _ = client.close_session(&session.id); - assert!(history.contains("scroll-01"), "the oldest received output was not retained"); - assert!(!history.contains("scroll-40"), "scrolling did not move away from the live viewport"); -} - -#[test] -#[ignore = "needs a real herdr daemon"] -fn full_screen_wheel_fallback_survives_the_real_frame_stream() { - let live = Live::start(); - let client = &live.client; - let workspace = - client.open_workspace(&std::env::temp_dir(), Some("zeddy-wheel-modes")).expect("workspace"); - let session = client.start_session(&workspace, None).expect("session"); - let size = Size::new(80, 24); - let attachment = - client.attach(&session.id, Geometry::new(size.cols, size.rows)).expect("attach"); - let (mut frames, mut input) = attachment.split(); - let mut terminal = Terminal::new(size); - - input - .send(b"printf '\\033[?1049h\\033[?1000h\\033[?1006hfull-tui-marker'\r") - .expect("enter full-screen mouse mode"); - - for _ in 0..20 { - let frame = frames.next_frame().expect("the stream stays valid").expect("a frame"); - if frame.full { - terminal.resize(Size::new(frame.geometry.cols, frame.geometry.rows)); - } - terminal.feed(&frame.bytes); - if terminal.screen().to_text().contains("full-tui-marker") { - break; - } - } + live.client.open_workspace(&std::env::temp_dir(), Some("zeddy-live")).expect("workspace"); + let session = live.client.start_session(&workspace, None).expect("session"); + let attach = live.client.direct_attach(&session.terminal); - assert!(terminal.screen().to_text().contains("full-tui-marker")); - let wheel = - WheelEvent { lines: 1, position: CellPosition::new(4, 2), modifiers: Modifiers::default() }; + assert!(!session.terminal.0.is_empty()); + assert_eq!(attach.program, std::path::Path::new("/usr/bin/env")); assert_eq!( - terminal.wheel_input(wheel), - None, - "Herdr's repaint stream currently omits the application's mouse mode", + &attach.args[attach.args.len() - 4..], + ["terminal", "attach", session.terminal.0.as_str(), "--takeover"] ); - assert_eq!( - terminal.wheel_input_with_fallback(wheel, WheelFallback::SgrMouse), - Some(b"\x1b[<64;5;3M".to_vec()), - "the control-plane fallback must restore the application's wheel input", - ); - let _ = client.close_session(&session.id); + assert!(attach.env.contains_key("HERDR_SOCKET_PATH")); + live.client.close_session(&session.id).expect("close session"); } #[test] #[ignore = "needs a real herdr daemon"] -fn a_broken_transport_recovers_without_resurrecting_dead_sessions() { +fn a_broken_backend_recovers_without_resurrecting_dead_sessions() { let mut live = Live::start(); let workspace = live .client .open_workspace(&std::env::temp_dir(), Some("zeddy-recovery")) .expect("workspace"); - let session = live.client.start_session(&workspace, None).expect("session"); - let attachment = live.client.attach(&session.id, Geometry::new(80, 24)).expect("attach"); - let (mut frames, mut input) = attachment.split(); + live.client.start_session(&workspace, None).expect("session"); live.crash(); - let (sent, received) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = sent.send(frames.next_frame()); - }); - let stream = received - .recv_timeout(Duration::from_secs(5)) - .expect("the frame stream notices daemon death"); - assert!(stream.is_err() || stream.expect("checked error").is_none()); - assert!(input.send(b"echo should-not-send\r").is_err()); - live.client.restart().expect("one clean replacement starts"); live.client.reconnect(Duration::from_secs(10)).expect("replacement answers"); assert!( diff --git a/docs/acceptance.md b/docs/acceptance.md index f826c069..36a5520f 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -15,9 +15,9 @@ shipping architecture: cargo test -p zeddy --test live_session -- --ignored --nocapture --test-threads=1 ``` -That suite must paint a real shell, load ANSI host scrollback, hard-kill Herdr, -observe the broken stream, replace the daemon, and reject the stale session -identity. +That suite must handshake the exact sidecar, create a persistent terminal, +produce a namespace-safe native attach target, hard-kill Herdr, replace the +daemon, and reject the stale session identity. ## Visual matrix @@ -43,7 +43,7 @@ Chartr Light. Capture and compare: corner resolution and no split target over a pane's tab bar; - one native, application-wide Settings window with General, Appearance, Terminal, Hotkeys, Plugins, and a contributed plugin Settings view; -- command palette, unavailable-folder recovery, broken-stream recovery, backend +- command palette, unavailable-folder recovery, closed-attach recovery, backend crash-loop banner, rejected plugin, and visible web permissions; - the native Hello pane and real Clock web pane, including its persisted format. @@ -59,16 +59,34 @@ directional focus, move-to-existing-pane, join, Settings singleton focus, native `Cmd/Ctrl+W` close, and `Ctrl+Tab` Settings-page cycling. Close the last workspace and confirm Settings closes too. -Print more than two viewports of styled output in a terminal. With both a mouse -wheel and a trackpad, move to the oldest row and back to the live prompt. Confirm -small pixel deltas accumulate smoothly, colors survive in history, new output -does not pull a historical viewport to the bottom, and scrolling up again after -returning to the prompt refreshes the host history. - -Open OpenCode, Claude Code, and Codex and scroll in both directions with a wheel -and a trackpad. Confirm each application viewport moves instead of host history. -Then hold Shift while scrolling and confirm the gesture reaches host scrollback -instead. +Exercise the terminal as a terminal, not only as a shell prompt: + +- paste single-line and multiline text with the platform shortcut and context + menu, then copy a pointer and keyboard selection back out; +- press Shift+Enter in a multiline-capable prompt and confirm it inserts LF + without submitting, while Enter submits normally; +- use Option/Alt+Left and Option/Alt+Right to move by words; +- print more than two viewports of styled Unicode output, then use both a mouse + wheel and a trackpad to reach the oldest row and return to the live prompt; +- run alternate-screen TUIs such as `less`, `nano`, and `htop`; confirm wheel, + trackpad, mouse clicks, dragging, arrow keys, function keys, and resize reports + reach the application instead of moving host scrollback; +- verify double/triple-click selection, select all, clear, scroll-to-top/bottom, + URL and filesystem hyperlinks, wide glyphs, combining marks, and IME + composition; +- open terminal search (`Cmd+F` on macOS, `Ctrl+Shift+F` elsewhere), confirm + literal punctuation is matched, cycle in both directions, and dismiss back + to the terminal without sending the search keystrokes to the shell; +- drag one or more files from the desktop into a terminal and confirm their + shell-quoted paths are pasted exactly once; +- change terminal font family and size while a terminal is visible and confirm + the grid reflows immediately without restart, clipping, or stale alignment; +- emit BEL and confirm the tab indicator appears, then type in that terminal + and confirm the indicator clears. + +Repeat the input and scrolling checks in standalone, grouped, and split panes, +including after detach/reattach and window resize. Reject any duplicate input, +stale viewport, focus loss, or interaction that works only in one pane shape. Confirm the active-space picker sits in the macOS title bar immediately after the traffic lights, and the chevron menu sits at the far-right corner in both diff --git a/docs/adr/0001-a-private-herdr.md b/docs/adr/0001-a-private-herdr.md index f43aac60..7d4cb504 100644 --- a/docs/adr/0001-a-private-herdr.md +++ b/docs/adr/0001-a-private-herdr.md @@ -18,14 +18,27 @@ stop` typed in one of zeddy's own terminals killing the window's backend. Resolving by path rather than through `PATH` follows from the same thing: a herdr the user installed is theirs, and picking it up would make zeddy's backend -version depend on the machine. The frame stream rides herdr's *command line*, +version depend on the machine. Direct terminal attachment rides Herdr's *command line*, which carries no compatibility promise, so the version is pinned exactly rather than as a floor. +The pin may be an immutable upstream source revision when a required backend +fix has not reached a release. In that case the acquisition script gives the +binary a revision-specific build version, and the ordinary version/protocol +handshake rejects released or locally built binaries that merely share the +same package version. This is currently required for semantic direct-attach +mouse forwarding: released Herdr 0.8.2 consumes click reports instead of +forwarding them according to the attached child's active mouse mode. + +Because the daemon can outlive the Chartr binary that launched it, a pin change +uses Herdr's live-handoff API when the private socket is occupied by an +incompatible version. The replacement sidecar inherits the live PTYs; Chartr +never stops an old daemon merely to upgrade it. + `Namespace::env` sets only Herdr's config root and exact socket, then clears `HERDR_SESSION`, `HERDR_PANE_ID`, and their siblings rather than merely overriding what it sets. Chartr is frequently launched *from* a Herdr pane, and -an inherited selector would otherwise point a frame stream at a daemon the +an inherited selector would otherwise point an attach client at a daemon the control plane is not talking to. ## What this rules out diff --git a/docs/adr/0004-the-vt-core.md b/docs/adr/0004-the-vt-core.md deleted file mode 100644 index 72e01caf..00000000 --- a/docs/adr/0004-the-vt-core.md +++ /dev/null @@ -1,62 +0,0 @@ -# 0004 — Alacritty output and Ghostty input behind one VT boundary - -## Decision - -`zeddy-vt` wraps two terminal cores for different jobs. Zed's pinned -`alacritty_terminal` parses repaint bytes and optional ANSI host history into a -`Screen`. The pinned safe `libghostty-vt` binding turns normalized key events -into mode-aware terminal input bytes. Neither upstream vocabulary crosses the -crate boundary. - -## Why - -Zeddy's renderer is built on Zed's frontend, and taking Zed's parser means the -grid semantics the renderer assumes and the grid semantics the parser produces -already agree. The traffic Zeddy parses is not where Ghostty's faster parser is -valuable. - -Keyboard encoding is different. Modified navigation, function keys, application -cursor/keypad modes, xterm extensions, fixterms, and the Kitty keyboard protocol -form a stateful protocol rather than a maintainable escape-sequence table. -Ghostty already implements that protocol and is also the encoder used by -chartr-rs. The Zig 0.16.0 build dependency is accepted for input fidelity; the -safe binding, Ghostty commit, and Zig version move as one deliberate pin. - -Herdr's frame stream remains a *re-render of its own emulated grid*, not the raw -output of the program in the PTY. Mode-aware encoding therefore uses every mode -the local parser can observe but cannot reconstruct modes Herdr omits. Legacy -Ghostty encoding is authoritative today; fully negotiated Kitty behavior -requires Herdr to carry structured keys or terminal mode state in the future. - -The same limitation affects wheel input: repaint frames omit alternate-screen -and mouse-tracking modes. Zeddy therefore uses observed modes when present and -a deliberately narrow control-plane fallback for Claude Code, OpenCode, and -Codex, whose foreground identities Herdr preserves and whose full-screen TUIs -request xterm SGR mouse input. Shift always bypasses application wheel handling -to expose host scrollback. Other foreground processes remain on host scrollback -rather than receiving guessed escape sequences. - -## Snapshots, not borrows - -`Terminal::screen` copies. A borrowed grid would be faster and would tie the -render pass to the lifetime of an emulator owned by a different thread than the -one painting. At the sizes a terminal runs — a few thousand cells — the copy is -not what makes a frame slow. - -## Host-backed scrollback - -The live emulator keeps `scrolling_history` at zero because herdr's frame stream -sends only viewport repaints; treating those repaints as raw PTY output creates -duplicate and missing history. On the first upward wheel gesture, zeddy reads -ANSI-styled `recent` history through Herdr's `pane.read` control method on a -background thread. `zeddy-vt` parses that into a separate historical emulator -and moves its display offset. Returning to offset zero renders the live emulator -again. New output marks a bottomed history snapshot stale, and a resize discards -it, so the next upward gesture asks Herdr for an authoritative replacement. - -## Colour is not resolved here - -A cell carries `Default`, `Indexed(n)`, or `Rgb`. What those *are* belongs to -the theme, and resolving them in this crate would hard-code one. `zeddy::palette` -is where it happens, which makes a theme switch a re-render rather than a -re-parse. diff --git a/docs/adr/0004-the-zed-terminal-stack.md b/docs/adr/0004-the-zed-terminal-stack.md new file mode 100644 index 00000000..c026f9e1 --- /dev/null +++ b/docs/adr/0004-the-zed-terminal-stack.md @@ -0,0 +1,77 @@ +# 0004 — Zed's complete terminal stack + +## Decision + +Chartr uses Zed's pinned `terminal` model and `terminal_view::TerminalView` +together, without a local emulator, renderer, input encoder, scrollback model, +or input path. A Zed-created local PTY runs Herdr's native interactive +`terminal attach --takeover` client. Herdr continues to own the +persistent PTY and process lifetime. + +The view source is vendored at the same exact Zed revision with one narrow host +capability: `TerminalVerticalAlignment`. Upstream behavior remains the default; +Chartr selects `Top` so leftover pixels smaller than a terminal row stay below +the grid instead of shifting the grid origin during resize. The patch and its +rebase procedure are recorded beside the vendored crate. + +## Why the complete stack + +A modern terminal is not a parser followed by a grid. Keyboard protocols, +alternate-screen scrolling, mouse reporting, selection, clipboard, IME, +hyperlinks, resize, scrollback, and rendering share state and edge cases. Using +only a VT library left Chartr responsible for the rest of that contract, which +is why individually reasonable fixes still failed to produce Zed-quality +behavior. + +The model and view at one Zed revision are already exercised together in Zed. +Keeping them together gives Chartr the same hot paths and the same interaction +semantics instead of asking Chartr to reproduce them around a parser. + +## Why not libghostty-vt + +`libghostty-vt` is a capable, high-performance VT engine. It is not a GPUI +terminal view or a complete desktop-terminal integration. Chartr would still +own rendering, input dispatch, clipboard, IME, mouse, keymaps, accessibility, +and their synchronization with its state. Choosing it would therefore optimize +one component while retaining the custom frontend this decision removes. + +## Persistence boundary + +Dropping a Zed terminal closes only the local Herdr attach client. The shell and +its PTY stay in the private Herdr daemon and can be attached again after a +Chartr relaunch. Closing a Chartr terminal item remains destructive because the +control plane explicitly closes the corresponding Herdr pane. + +The Herdr crate returns one complete, namespace-safe attach command. The Zed +host consumes that specification without knowing Herdr flags or environment +rules. + +## Host and customization boundary + +Chartr is not a Zed `Workspace` or `Project`. One `terminal_host` adapter passes +absent weak handles to `TerminalView` and selects its documented +non-workspace-host behavior by hiding workspace-only actions. It does not create +a fake Zed workspace and it does not reimplement any terminal behavior. A +`SpaceEvent::TerminalReady` creates the view with its window and installs it on +the stable session item before rendering; render code only reads and mounts the +existing entity. + +Customization remains available through Zed terminal settings, Chartr's theme +settings provider, and Zed's pinned default terminal keymap. Chartr filters that +keymap by terminal action namespace instead of copying a subset, then adds only +product-level actions such as terminal-buffer search. Product layout and +lifecycle stay in Chartr. + +The host boundary supplies integrations that cannot exist without product +context: file drops paste shell-quoted paths, filesystem links open through the +desktop, and terminal BEL state appears in Chartr chrome. These are callbacks +around public terminal APIs, not alternate input, rendering, or emulation paths. + +## Cost + +`terminal_view` brings a larger portion of Zed's pinned dependency graph and a +small source-vendoring burden. A rendered regression test covers the maintained +alignment branch at adjacent pane heights. Zed's workspace patches are mirrored +at the same revisions so the external build is reproducible. This is an +intentional build-size tradeoff for sharing the tested terminal implementation +rather than maintaining a parallel one. diff --git a/docs/adr/README.md b/docs/adr/README.md index 19f79b6f..51f6ca04 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,5 +8,5 @@ have to change for it to be worth revisiting. - [0001 — Sessions live in a private herdr](0001-a-private-herdr.md) - [0002 — The Zed layer, and what it costs](0002-the-zed-layer.md) - [0003 — Two plugin tiers](0003-two-plugin-tiers.md) -- [0004 — Alacritty output and Ghostty input behind one VT boundary](0004-the-vt-core.md) +- [0004 — Zed's complete terminal stack](0004-the-zed-terminal-stack.md) - [0005 — Spaces follow Zed's multi-workspace ownership](0005-spaces-follow-zed-multi-workspace.md) diff --git a/vendor/herdr/fetch.sh b/vendor/herdr/fetch.sh index f5f0c4ae..4f07d84f 100755 --- a/vendor/herdr/fetch.sh +++ b/vendor/herdr/fetch.sh @@ -6,15 +6,17 @@ # `cargo build`. # # sh vendor/herdr/fetch.sh # this machine's target -# sh vendor/herdr/fetch.sh … # named targets +# sh vendor/herdr/fetch.sh … # named targets (cross toolchain required) # # Each executable lands at `vendor/herdr//herdr`, which is gitignored: # they belong to herdr, not to this history. `crates/zeddy/build.rs` copies the # one for the target being built in beside the zeddy binary. # -# The version is not a flag. It is read from `SUPPORTED_HERDR_VERSION`, the one -# place zeddy pins herdr, so a vendored binary and the client that drives it -# cannot disagree about which release this is. +# Herdr's latest release predates semantic mouse forwarding for direct attach. +# Until that change is tagged, this builds one immutable upstream revision and +# brands it with a Zeddy-specific version. The runtime handshake therefore +# rejects both an older release binary and an arbitrary build of the same +# upstream package version. set -eu @@ -23,19 +25,23 @@ root=$(cd "$here/../.." && pwd) version=$(sed -n 's/^pub const SUPPORTED_HERDR_VERSION: &str = "\(.*\)";$/\1/p' \ "$root/crates/zeddy-herdr/src/lib.rs") -[ -n "$version" ] || { - echo "cannot read SUPPORTED_HERDR_VERSION from crates/zeddy-herdr/src/lib.rs" >&2 +upstream_version=$(sed -n \ + 's/^pub const SUPPORTED_HERDR_UPSTREAM_VERSION: &str = "\(.*\)";$/\1/p' \ + "$root/crates/zeddy-herdr/src/lib.rs") +revision=$(sed -n \ + 's/^pub const SUPPORTED_HERDR_REVISION: &str = "\([0-9a-f][0-9a-f]*\)";$/\1/p' \ + "$root/crates/zeddy-herdr/src/lib.rs") +[ -n "$version" ] && [ -n "$upstream_version" ] && [ -n "$revision" ] || { + echo "cannot read the Herdr pins from crates/zeddy-herdr/src/lib.rs" >&2 exit 1 } -# herdr publishes no Windows build, and `zeddy-herdr` does not compile there -# either — its control plane is a Unix domain socket. -asset_for() { +# Herdr does not support Zeddy's Windows control plane; keep accepted targets +# explicit so a typo cannot silently produce a sidecar in the wrong directory. +supported_target() { case "$1" in - aarch64-apple-darwin) echo "herdr-macos-aarch64" ;; - x86_64-apple-darwin) echo "herdr-macos-x86_64" ;; - aarch64-unknown-linux-gnu) echo "herdr-linux-aarch64" ;; - x86_64-unknown-linux-gnu) echo "herdr-linux-x86_64" ;; + aarch64-apple-darwin | x86_64-apple-darwin | \ + aarch64-unknown-linux-gnu | x86_64-unknown-linux-gnu) return 0 ;; *) return 1 ;; esac } @@ -51,19 +57,69 @@ host_target() { targets=${*:-$(host_target)} +zig_bin=${ZIG:-} +if [ -z "$zig_bin" ] && command -v brew >/dev/null 2>&1; then + brew_zig=$(brew --prefix zig@0.15 2>/dev/null || true) + if [ -x "$brew_zig/bin/zig" ]; then + zig_bin="$brew_zig/bin/zig" + fi +fi +if [ -z "$zig_bin" ] && command -v zig >/dev/null 2>&1; then + zig_bin=$(command -v zig) +fi +[ -n "$zig_bin" ] || { + echo "building Herdr requires Zig 0.15.2 (set ZIG to its executable)" >&2 + exit 1 +} +case $("$zig_bin" version) in +0.15.2) ;; +*) + echo "building Herdr requires Zig 0.15.2; $zig_bin is $("$zig_bin" version)" >&2 + exit 1 + ;; +esac + +work=$(mktemp -d "${TMPDIR:-/tmp}/zeddy-herdr.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +source_dir="$work/source" +mkdir -p "$source_dir" + +echo "fetching Herdr source $revision" +curl -fsSL "https://github.com/herdrdev/herdr/archive/$revision.tar.gz" \ + -o "$work/herdr.tar.gz" +tar -xzf "$work/herdr.tar.gz" -C "$source_dir" --strip-components=1 + +actual_upstream_version=$(sed -n \ + 's/^version = "\(.*\)"$/\1/p' "$source_dir/Cargo.toml" | head -1) +[ "$actual_upstream_version" = "$upstream_version" ] || { + echo "revision $revision is Herdr $actual_upstream_version, expected $upstream_version" >&2 + exit 1 +} + +build_id=$(printf '%s' "$revision" | cut -c1-12) for target in $targets; do - asset=$(asset_for "$target") || { - echo "no herdr release asset for $target" >&2 + supported_target "$target" || { + echo "unsupported Herdr target: $target" >&2 exit 1 } + + echo "building Herdr $version for $target" + ( + cd "$source_dir" + CARGO_TARGET_DIR="$work/target" \ + HERDR_BUILD_CHANNEL=zeddy \ + HERDR_BUILD_ID="$build_id" \ + HERDR_BUILD_COMMIT="$revision" \ + ZIG="$zig_bin" \ + cargo build --release --locked --target "$target" + ) + dir="$here/$target" mkdir -p "$dir" - url="https://github.com/herdrdev/herdr/releases/download/v$version/$asset" - echo "fetching herdr $version for $target" - curl -fsSL "$url" -o "$dir/herdr" + cp "$work/target/$target/release/herdr" "$dir/herdr" chmod +x "$dir/herdr" done -curl -fsSL "https://raw.githubusercontent.com/herdrdev/herdr/v$version/LICENSE" \ +curl -fsSL "https://raw.githubusercontent.com/herdrdev/herdr/$revision/LICENSE" \ -o "$here/LICENSE" -echo "vendored herdr $version" +echo "vendored Herdr $version from $revision" diff --git a/vendor/zed-terminal-view/CHARTR-PATCH.md b/vendor/zed-terminal-view/CHARTR-PATCH.md new file mode 100644 index 00000000..2abe308a --- /dev/null +++ b/vendor/zed-terminal-view/CHARTR-PATCH.md @@ -0,0 +1,19 @@ +# Chartr patch + +This crate's source is an otherwise unchanged copy of Zed's `terminal_view` +crate at commit `1ea16c1ab9dd6d36649e002dc60995634da04daf`. + +Chartr adds one host policy: + +- `TerminalVerticalAlignment` and `TerminalView::set_vertical_alignment` let a + non-Zed host choose whether spare sub-row pixels are placed above or below + the terminal grid. +- Zed's existing `BottomWhenFull` behavior remains the default. +- Chartr selects `Top`, keeping the grid origin stable while a pane is resized. + +When updating the pinned Zed revision, replace this directory from upstream +first, then reapply only the alignment enum, field, setter, layout branch, and +their tests. + +`Cargo.toml` declares the upstream workspace dependencies explicitly so this +crate can build from Chartr's workspace without pretending to be part of Zed's. diff --git a/vendor/zed-terminal-view/Cargo.toml b/vendor/zed-terminal-view/Cargo.toml new file mode 100644 index 00000000..3f8e6cb4 --- /dev/null +++ b/vendor/zed-terminal-view/Cargo.toml @@ -0,0 +1,48 @@ +# Vendored from zed-industries/zed at 1ea16c1ab9dd6d36649e002dc60995634da04daf. +# Chartr's narrow divergence is documented in CHARTR-PATCH.md. +[package] +name = "terminal_view" +version = "0.1.0" +edition = "2024" +publish = false +license = "GPL-3.0-or-later" + +[features] +test-support = ["editor/test-support", "gpui/test-support"] + +[lib] +path = "src/terminal_view.rs" +doctest = false + +[dependencies] +anyhow = "1" +async-recursion = "1.0.0" +dirs = "6.0" +futures = "0.3" +itertools = "0.14.0" +log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } +pretty_assertions = { version = "1.3.0", features = ["unstable"] } +regex = "1.5" +schemars = { version = "1.0", features = ["indexmap2"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +shellexpand = "3.1" +shlex = "1.3.0" + +breadcrumbs = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +collections = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +db = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +editor = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +gpui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf", default-features = false } +language = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +menu = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +project = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +settings = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +task = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +terminal = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +theme = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +theme_settings = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +ui = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +util = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +workspace = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } +zed_actions = { git = "https://github.com/zed-industries/zed.git", rev = "1ea16c1ab9dd6d36649e002dc60995634da04daf" } diff --git a/vendor/zed-terminal-view/LICENSE-GPL b/vendor/zed-terminal-view/LICENSE-GPL new file mode 120000 index 00000000..89e542f7 --- /dev/null +++ b/vendor/zed-terminal-view/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/vendor/zed-terminal-view/README.md b/vendor/zed-terminal-view/README.md new file mode 100644 index 00000000..2f037246 --- /dev/null +++ b/vendor/zed-terminal-view/README.md @@ -0,0 +1,37 @@ +# Terminal View + +## Design Notes + +This crate is split into two conceptual halves: +- The terminal.rs file and the src/mappings/ folder, these contain the code for interacting with terminal emulator backends and maintaining the pty event loop. Some behavior in this file is constrained by terminal protocols and standards. The Zed init function is also placed here. +- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file. + +ttys are created externally, and so can fail in unexpected ways. However, GPUI currently does not have an API for models than can fail to instantiate. `TerminalBuilder` solves this by using Rust's type system to split tty instantiation into a 2 step process: first attempt to create the file handles with `TerminalBuilder::new()`, check the result, then call `TerminalBuilder::subscribe(cx)` from within a model context. + +The TerminalView struct abstracts over failed and successful terminals, passing focus through to the associated view and allowing clients to build a terminal without worrying about errors. + +## Backend Boundary + +`terminal.rs` exposes backend-neutral domain types such as terminal content, cells, modes, points, ranges, scroll commands, vi motions, hyperlinks, and search matches. UI code should depend on those types instead of importing backend-specific terminal types directly. + +The current implementation is still Alacritty-backed, but the abstraction boundary keeps backend details concentrated in the terminal crate: + +- `terminal_view` renders `TerminalContent` and dispatches backend-neutral actions. +- Panels and tools use terminal-domain types for mode, cursor, range, hyperlink, and search behavior. +- Backend-specific conversions stay near the terminal event loop and render snapshot code. + +This keeps the user-facing terminal behavior unchanged while making future backend experiments reviewable as backend implementations instead of UI-wide refactors. + +## Input + +There are currently many distinct paths for getting keystrokes to the terminal: + +1. Terminal specific characters and bindings. Things like ctrl-a mapping to ASCII control character 1, ANSI escape codes associated with the function keys, etc. These are caught with a raw key-down handler in the element and are processed immediately. This is done with the `try_keystroke()` method on Terminal + +2. GPU Action handlers. GPUI clobbers a few vital keys by adding bindings to them in the global context. These keys are synthesized and then dispatched through the same `try_keystroke()` API as the above mappings + +3. IME text. When the special character mappings fail, we pass the keystroke back to GPUI to hand it to the IME system. This comes back to us in the `View::replace_text_in_range()` method, and we then send that to the terminal directly, bypassing `try_keystroke()`. + +4. Pasted text has a separate pathway. + +Generally, there's a distinction between 'keystrokes that need to be mapped' and 'strings which need to be written'. I've attempted to unify these under the '.try_keystroke()' API and the `.input()` API (which try_keystroke uses) so we have consistent input handling across the terminal diff --git a/vendor/zed-terminal-view/rustfmt.toml b/vendor/zed-terminal-view/rustfmt.toml new file mode 100644 index 00000000..7c72a4d8 --- /dev/null +++ b/vendor/zed-terminal-view/rustfmt.toml @@ -0,0 +1,3 @@ +# Keep the vendored source formatted exactly like its pinned upstream. +edition = "2024" +style_edition = "2024" diff --git a/vendor/zed-terminal-view/scripts/print256color.sh b/vendor/zed-terminal-view/scripts/print256color.sh new file mode 100755 index 00000000..9cb3b1c4 --- /dev/null +++ b/vendor/zed-terminal-view/scripts/print256color.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +# Tom Hale, 2016. MIT Licence. +# Print out 256 colours, with each number printed in its corresponding colour +# See http://askubuntu.com/questions/821157/print-a-256-color-test-pattern-in-the-terminal/821163#821163 + +set -eu # Fail on errors or undeclared variables + +printable_colours=256 + +# Return a colour that contrasts with the given colour +# Bash only does integer division, so keep it integral +function contrast_colour { + local r g b luminance + colour="$1" + + if (( colour < 16 )); then # Initial 16 ANSI colours + (( colour == 0 )) && printf "15" || printf "0" + return + fi + + # Greyscale # rgb_R = rgb_G = rgb_B = (number - 232) * 10 + 8 + if (( colour > 231 )); then # Greyscale ramp + (( colour < 244 )) && printf "15" || printf "0" + return + fi + + # All other colours: + # 6x6x6 colour cube = 16 + 36*R + 6*G + B # Where RGB are [0..5] + # See http://stackoverflow.com/a/27165165/5353461 + + # r=$(( (colour-16) / 36 )) + g=$(( ((colour-16) % 36) / 6 )) + # b=$(( (colour-16) % 6 )) + + # If luminance is bright, print number in black, white otherwise. + # Green contributes 587/1000 to human perceived luminance - ITU R-REC-BT.601 + (( g > 2)) && printf "0" || printf "15" + return + + # Uncomment the below for more precise luminance calculations + + # # Calculate perceived brightness + # # See https://www.w3.org/TR/AERT#color-contrast + # # and http://www.itu.int/rec/R-REC-BT.601 + # # Luminance is in range 0..5000 as each value is 0..5 + # luminance=$(( (r * 299) + (g * 587) + (b * 114) )) + # (( $luminance > 2500 )) && printf "0" || printf "15" +} + +# Print a coloured block with the number of that colour +function print_colour { + local colour="$1" contrast + contrast=$(contrast_colour "$1") + printf "\e[48;5;%sm" "$colour" # Start block of colour + printf "\e[38;5;%sm%3d" "$contrast" "$colour" # In contrast, print number + printf "\e[0m " # Reset colour +} + +# Starting at $1, print a run of $2 colours +function print_run { + local i + for (( i = "$1"; i < "$1" + "$2" && i < printable_colours; i++ )) do + print_colour "$i" + done + printf " " +} + +# Print blocks of colours +function print_blocks { + local start="$1" i + local end="$2" # inclusive + local block_cols="$3" + local block_rows="$4" + local blocks_per_line="$5" + local block_length=$((block_cols * block_rows)) + + # Print sets of blocks + for (( i = start; i <= end; i += (blocks_per_line-1) * block_length )) do + printf "\n" # Space before each set of blocks + # For each block row + for (( row = 0; row < block_rows; row++ )) do + # Print block columns for all blocks on the line + for (( block = 0; block < blocks_per_line; block++ )) do + print_run $(( i + (block * block_length) )) "$block_cols" + done + (( i += block_cols )) # Prepare to print the next row + printf "\n" + done + done +} + +print_run 0 16 # The first 16 colours are spread over the whole spectrum +printf "\n" +print_blocks 16 231 6 6 3 # 6x6x6 colour cube between 16 and 231 inclusive +print_blocks 232 255 12 2 1 # Not 50, but 24 Shades of Grey diff --git a/vendor/zed-terminal-view/scripts/truecolor.sh b/vendor/zed-terminal-view/scripts/truecolor.sh new file mode 100755 index 00000000..622051f2 --- /dev/null +++ b/vendor/zed-terminal-view/scripts/truecolor.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Copied from: https://unix.stackexchange.com/a/696756 +# Based on: https://gist.github.com/XVilka/8346728 and https://unix.stackexchange.com/a/404415/395213 + +awk -v term_cols="${width:-$(tput cols || echo 80)}" -v term_lines="${height:-1}" 'BEGIN{ + s="/\\"; + total_cols=term_cols*term_lines; + for (colnum = 0; colnum255) g = 510-g; + printf "\033[48;2;%d;%d;%dm", r,g,b; + printf "\033[38;2;%d;%d;%dm", 255-r,255-g,255-b; + printf "%s\033[0m", substr(s,colnum%2+1,1); + if (colnum%term_cols==term_cols) printf "\n"; + } + printf "\n"; +}' diff --git a/vendor/zed-terminal-view/src/persistence.rs b/vendor/zed-terminal-view/src/persistence.rs new file mode 100644 index 00000000..5c1e659b --- /dev/null +++ b/vendor/zed-terminal-view/src/persistence.rs @@ -0,0 +1,507 @@ +use anyhow::Result; +use async_recursion::async_recursion; +use collections::HashSet; +use futures::future::join_all; +use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use ui::{App, Context, Window}; +use util::ResultExt as _; + +use db::{ + query, + sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; +use workspace::{ + ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, + WorkspaceDb, WorkspaceId, +}; + +use crate::{ + TerminalView, default_working_directory, + terminal_panel::{TerminalPanel, new_terminal_pane}, +}; + +pub(crate) fn serialize_pane_group( + pane_group: &PaneGroup, + active_pane: &Entity, + cx: &mut App, +) -> SerializedPaneGroup { + build_serialized_pane_group(&pane_group.root, active_pane, cx) +} + +fn build_serialized_pane_group( + pane_group: &Member, + active_pane: &Entity, + cx: &mut App, +) -> SerializedPaneGroup { + match pane_group { + Member::Axis(PaneAxis { + axis, + members, + flexes, + bounding_boxes: _, + }) => SerializedPaneGroup::Group { + axis: SerializedAxis(*axis), + children: members + .iter() + .map(|member| build_serialized_pane_group(member, active_pane, cx)) + .collect::>(), + flexes: Some(flexes.lock().clone()), + }, + Member::Pane(pane_handle) => { + SerializedPaneGroup::Pane(serialize_pane(pane_handle, pane_handle == active_pane, cx)) + } + } +} + +fn serialize_pane(pane: &Entity, active: bool, cx: &mut App) -> SerializedPane { + let mut items_to_serialize = HashSet::default(); + let pane = pane.read(cx); + let children = pane + .items() + .filter_map(|item| { + let terminal_view = item.act_as::(cx)?; + if terminal_view.read(cx).terminal().read(cx).task().is_some() { + None + } else { + let id = item.item_id().as_u64(); + items_to_serialize.insert(id); + Some(id) + } + }) + .collect::>(); + let active_item = pane + .active_item() + .map(|item| item.item_id().as_u64()) + .filter(|active_id| items_to_serialize.contains(active_id)); + + let pinned_count = pane.pinned_count(); + SerializedPane { + active, + children, + active_item, + pinned_count, + } +} + +pub(crate) fn deserialize_terminal_panel( + workspace: WeakEntity, + project: Entity, + database_id: WorkspaceId, + serialized_panel: SerializedTerminalPanel, + window: &mut Window, + cx: &mut App, +) -> Task>> { + window.spawn(cx, async move |cx| { + let terminal_panel = workspace.update_in(cx, |workspace, window, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + })?; + match &serialized_panel.items { + SerializedItems::NoSplits(item_ids) => { + let items = deserialize_terminal_views( + database_id, + project, + workspace, + item_ids.as_slice(), + cx, + ) + .await; + let active_item = serialized_panel.active_item_id; + terminal_panel.update_in(cx, |terminal_panel, window, cx| { + terminal_panel.active_pane.update(cx, |pane, cx| { + populate_pane_items(pane, items, active_item, window, cx); + }); + })?; + } + SerializedItems::WithSplits(serialized_pane_group) => { + let center_pane = deserialize_pane_group( + workspace, + project, + terminal_panel.clone(), + database_id, + serialized_pane_group, + cx, + ) + .await; + if let Some((center_group, active_pane)) = center_pane { + terminal_panel.update(cx, |terminal_panel, _| { + terminal_panel.center = PaneGroup::with_root(center_group); + terminal_panel.active_pane = + active_pane.unwrap_or_else(|| terminal_panel.center.first_pane()); + }); + } + } + } + + Ok(terminal_panel) + }) +} + +fn populate_pane_items( + pane: &mut Pane, + items: Vec>, + active_item: Option, + window: &mut Window, + cx: &mut Context, +) { + let mut active_item_index = None; + for (item_index, item) in (pane.items_len()..).zip(items) { + if Some(item.item_id().as_u64()) == active_item { + active_item_index = Some(item_index); + } + pane.add_item(Box::new(item), false, false, None, window, cx); + } + if let Some(index) = active_item_index { + pane.activate_item(index, false, false, window, cx); + } +} + +#[async_recursion(?Send)] +async fn deserialize_pane_group( + workspace: WeakEntity, + project: Entity, + panel: Entity, + workspace_id: WorkspaceId, + serialized: &SerializedPaneGroup, + cx: &mut AsyncWindowContext, +) -> Option<(Member, Option>)> { + match serialized { + SerializedPaneGroup::Group { + axis, + flexes, + children, + } => { + let mut current_active_pane = None; + let mut members = Vec::new(); + for child in children { + if let Some((new_member, active_pane)) = deserialize_pane_group( + workspace.clone(), + project.clone(), + panel.clone(), + workspace_id, + child, + cx, + ) + .await + { + members.push(new_member); + current_active_pane = current_active_pane.or(active_pane); + } + } + + if members.is_empty() { + return None; + } + + if members.len() == 1 { + return Some((members.remove(0), current_active_pane)); + } + + Some(( + Member::Axis(PaneAxis::load(axis.0, members, flexes.clone())), + current_active_pane, + )) + } + SerializedPaneGroup::Pane(serialized_pane) => { + let active = serialized_pane.active; + + let pane = panel + .update_in(cx, |terminal_panel, window, cx| { + new_terminal_pane( + workspace.clone(), + project.clone(), + terminal_panel.active_pane.read(cx).is_zoomed(), + window, + cx, + ) + }) + .log_err()?; + let active_item = serialized_pane.active_item; + let pinned_count = serialized_pane.pinned_count; + let new_items = deserialize_terminal_views( + workspace_id, + project.clone(), + workspace.clone(), + serialized_pane.children.as_slice(), + cx, + ); + cx.spawn({ + let pane = pane.downgrade(); + async move |cx| { + let new_items = new_items.await; + + let items = pane.update_in(cx, |pane, window, cx| { + populate_pane_items(pane, new_items, active_item, window, cx); + pane.set_pinned_count(pinned_count.min(pane.items_len())); + pane.items_len() + }); + // Avoid blank panes in splits + if items.is_ok_and(|items| items == 0) { + let working_directory = workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + .ok() + .flatten(); + let terminal = project + .update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }) + .await + .log_err(); + let Some(terminal) = terminal else { + return; + }; + pane.update_in(cx, |pane, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal, + workspace.clone(), + Some(workspace_id), + project.downgrade(), + window, + cx, + ) + })); + pane.add_item(terminal_view, true, false, None, window, cx); + }) + .ok(); + } + } + }) + .await; + Some((Member::Pane(pane.clone()), active.then_some(pane))) + } + } +} + +fn deserialize_terminal_views( + workspace_id: WorkspaceId, + project: Entity, + workspace: WeakEntity, + item_ids: &[u64], + cx: &mut AsyncWindowContext, +) -> impl Future>> + use<> { + let deserialized_items = join_all(item_ids.iter().filter_map(|item_id| { + cx.update(|window, cx| { + TerminalView::deserialize( + project.clone(), + workspace.clone(), + workspace_id, + *item_id, + window, + cx, + ) + }) + .ok() + })); + async move { + deserialized_items + .await + .into_iter() + .filter_map(|item| item.log_err()) + .collect() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedTerminalPanel { + pub items: SerializedItems, + // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. + pub active_item_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum SerializedItems { + // The data stored before terminal splits were introduced. + NoSplits(Vec), + WithSplits(SerializedPaneGroup), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SerializedPaneGroup { + Pane(SerializedPane), + Group { + axis: SerializedAxis, + flexes: Option>, + children: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedPane { + pub active: bool, + pub children: Vec, + pub active_item: Option, + #[serde(default)] + pub pinned_count: usize, +} + +#[derive(Debug)] +pub(crate) struct SerializedAxis(pub Axis); + +impl Serialize for SerializedAxis { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.0 { + Axis::Horizontal => serializer.serialize_str("horizontal"), + Axis::Vertical => serializer.serialize_str("vertical"), + } + } +} + +impl<'de> Deserialize<'de> for SerializedAxis { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "horizontal" => Ok(SerializedAxis(Axis::Horizontal)), + "vertical" => Ok(SerializedAxis(Axis::Vertical)), + invalid => Err(serde::de::Error::custom(format!( + "Invalid axis value: '{invalid}'" + ))), + } + } +} + +pub struct TerminalDb(ThreadSafeConnection); + +impl Domain for TerminalDb { + const NAME: &str = stringify!(TerminalDb); + + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE terminals ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + working_directory BLOB, + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + ), + // Remove the unique constraint on the item_id table + // SQLite doesn't have a way of doing this automatically, so + // we have to do this silly copying. + sql!( + CREATE TABLE terminals2 ( + workspace_id INTEGER, + item_id INTEGER, + working_directory BLOB, + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + + INSERT INTO terminals2 (workspace_id, item_id, working_directory) + SELECT workspace_id, item_id, working_directory FROM terminals; + + DROP TABLE terminals; + + ALTER TABLE terminals2 RENAME TO terminals; + ), + sql! ( + ALTER TABLE terminals ADD COLUMN working_directory_path TEXT; + UPDATE terminals SET working_directory_path = CAST(working_directory AS TEXT); + ), + sql! ( + ALTER TABLE terminals ADD COLUMN custom_title TEXT; + ), + ]; +} + +db::static_connection!(TerminalDb, [WorkspaceDb]); + +impl TerminalDb { + query! { + pub async fn update_workspace_id( + new_id: WorkspaceId, + old_id: WorkspaceId, + item_id: ItemId + ) -> Result<()> { + UPDATE terminals + SET workspace_id = ? + WHERE workspace_id = ? AND item_id = ? + } + } + + pub async fn save_working_directory( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + working_directory: PathBuf, + ) -> Result<()> { + log::debug!( + "Saving working directory {working_directory:?} for item {item_id} in workspace {workspace_id:?}" + ); + let query = + "INSERT INTO terminals(item_id, workspace_id, working_directory, working_directory_path) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT DO UPDATE SET + item_id = ?1, + workspace_id = ?2, + working_directory = ?3, + working_directory_path = ?4" + ; + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&item_id, 1)?; + next_index = statement.bind(&workspace_id, next_index)?; + next_index = statement.bind(&working_directory, next_index)?; + statement.bind( + &working_directory.to_string_lossy().into_owned(), + next_index, + )?; + statement.exec() + }) + .await + } + + query! { + pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + SELECT working_directory + FROM terminals + WHERE item_id = ? AND workspace_id = ? + } + } + + pub async fn save_custom_title( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + custom_title: Option, + ) -> Result<()> { + log::debug!( + "Saving custom title {:?} for item {} in workspace {:?}", + custom_title, + item_id, + workspace_id + ); + self.write(move |conn| { + let query = "INSERT INTO terminals (item_id, workspace_id, custom_title) + VALUES (?1, ?2, ?3) + ON CONFLICT (workspace_id, item_id) DO UPDATE SET + custom_title = excluded.custom_title"; + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&item_id, 1)?; + next_index = statement.bind(&workspace_id, next_index)?; + statement.bind(&custom_title, next_index)?; + statement.exec() + }) + .await + } + + query! { + pub fn get_custom_title(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + SELECT custom_title + FROM terminals + WHERE item_id = ? AND workspace_id = ? + } + } +} diff --git a/vendor/zed-terminal-view/src/terminal_element.rs b/vendor/zed-terminal-view/src/terminal_element.rs new file mode 100644 index 00000000..049c4edb --- /dev/null +++ b/vendor/zed-terminal-view/src/terminal_element.rs @@ -0,0 +1,3011 @@ +use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine}; +use gpui::{ + AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, + Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, + GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, + IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, + Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, + UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative, + size, +}; +use itertools::Itertools; +use language::CursorShape as EditorCursorShape; +use settings::Settings; +use std::time::Instant; +use terminal::{ + Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal, + TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, + is_default_background_color, terminal_settings::TerminalSettings, +}; +use theme::{ActiveTheme, Theme}; +use theme_settings::ThemeSettings; +use ui::utils::ensure_minimum_contrast; +use ui::{ParentElement, Tooltip}; +use util::ResultExt; +use workspace::Workspace; + +use std::mem; +use std::{fmt::Debug, rc::Rc}; + +use crate::{ + BlockContext, BlockProperties, ContentMode, TerminalMode, TerminalVerticalAlignment, + TerminalView, +}; + +/// The information generated during layout that is necessary for painting. +pub struct LayoutState { + hitbox: Hitbox, + batched_text_runs: Vec, + block_element_rects: Vec, + rects: Vec, + relative_highlighted_ranges: Vec<(Range, Hsla)>, + cursor: Option, + ime_cursor_bounds: Option>, + background_color: Hsla, + dimensions: TerminalBounds, + mode: Modes, + display_offset: usize, + hyperlink_tooltip: Option, + block_below_cursor_element: Option, + base_text_style: TextStyle, + content_mode: ContentMode, +} + +/// Helper struct for converting terminal cursor points to displayed cursor points. +#[derive(Copy, Clone)] +struct DisplayCursor { + line: i32, + col: usize, +} + +impl DisplayCursor { + fn from(cursor_point: Point, display_offset: usize) -> Self { + Self { + line: cursor_point.line + display_offset as i32, + col: cursor_point.column, + } + } + + pub fn line(&self) -> i32 { + self.line + } + + pub fn col(&self) -> usize { + self.col + } +} + +#[derive(Copy, Clone, Debug, Default)] +pub struct LayoutPoint { + line: i32, + column: i32, +} + +impl LayoutPoint { + fn new(line: i32, column: i32) -> Self { + Self { line, column } + } + + pub fn line(&self) -> i32 { + self.line + } + + pub fn column(&self) -> i32 { + self.column + } +} + +/// A batched text run that combines multiple adjacent cells with the same style +#[derive(Debug)] +pub struct BatchedTextRun { + pub start_point: LayoutPoint, + pub text: String, + pub cell_count: usize, + pub style: TextRun, + pub font_size: AbsoluteLength, +} + +impl BatchedTextRun { + fn new_from_char( + start_point: LayoutPoint, + c: char, + style: TextRun, + font_size: AbsoluteLength, + ) -> Self { + let mut text = String::with_capacity(100); // Pre-allocate for typical line length + text.push(c); + BatchedTextRun { + start_point, + text, + cell_count: 1, + style, + font_size, + } + } + + fn can_append(&self, other_style: &TextRun) -> bool { + self.style.font == other_style.font + && self.style.color == other_style.color + && self.style.background_color == other_style.background_color + && self.style.underline == other_style.underline + && self.style.strikethrough == other_style.strikethrough + } + + fn append_char(&mut self, c: char) { + self.append_char_internal(c, true); + } + + fn append_zero_width_chars(&mut self, chars: &[char]) { + for &c in chars { + self.append_char_internal(c, false); + } + } + + fn append_char_internal(&mut self, c: char, counts_cell: bool) { + self.text.push(c); + if counts_cell { + self.cell_count += 1; + } + self.style.len += c.len_utf8(); + } + + pub fn paint( + &self, + origin: GpuiPoint, + dimensions: &TerminalBounds, + window: &mut Window, + cx: &mut App, + ) { + let pos = GpuiPoint::new( + origin.x + self.start_point.column as f32 * dimensions.cell_width, + origin.y + self.start_point.line as f32 * dimensions.line_height, + ); + + window + .text_system() + .shape_line( + self.text.clone().into(), + self.font_size.to_pixels(window.rem_size()), + std::slice::from_ref(&self.style), + Some(dimensions.cell_width), + ) + .paint( + pos, + dimensions.line_height, + gpui::TextAlign::Left, + None, + window, + cx, + ) + .log_err(); + } +} + +/// Block element glyphs are painted on a subcell grid: each terminal cell is +/// divided into 8 columns (for eighth blocks) and 24 lines (LCM of the 8-way +/// splits of eighth blocks and the 3-way splits of sextants). +const BLOCK_SUBCELL_COLUMNS: i32 = 8; +const BLOCK_SUBCELL_LINES: i32 = 24; + +#[derive(Clone, Debug)] +pub struct BlockElementLayoutRect { + point: LayoutPoint, + num_of_columns: usize, + num_of_lines: usize, + color: Hsla, +} + +impl BlockElementLayoutRect { + fn new(point: LayoutPoint, num_of_columns: usize, num_of_lines: usize, color: Hsla) -> Self { + Self { + point, + num_of_columns, + num_of_lines, + color, + } + } + + pub fn paint( + &self, + origin: GpuiPoint, + dimensions: &TerminalBounds, + window: &mut Window, + ) { + let subcell_width = dimensions.cell_width / BLOCK_SUBCELL_COLUMNS as f32; + let subcell_height = dimensions.line_height / BLOCK_SUBCELL_LINES as f32; + let position = point( + origin.x + self.point.column as f32 * subcell_width, + origin.y + self.point.line as f32 * subcell_height, + ); + let size = size( + subcell_width * self.num_of_columns as f32, + subcell_height * self.num_of_lines as f32, + ); + + window.paint_quad(fill(Bounds::new(position, size), self.color)); + } + + pub fn line(&self) -> i32 { + (self.point.line + self.num_of_lines as i32 - 1) / BLOCK_SUBCELL_LINES + } +} + +#[derive(Clone, Debug, Default)] +pub struct LayoutRect { + point: LayoutPoint, + num_of_cells: usize, + color: Hsla, +} + +impl LayoutRect { + fn new(point: LayoutPoint, num_of_cells: usize, color: Hsla) -> LayoutRect { + LayoutRect { + point, + num_of_cells, + color, + } + } + + pub fn paint( + &self, + origin: GpuiPoint, + dimensions: &TerminalBounds, + window: &mut Window, + ) { + let position = { + let layout_point = self.point; + point( + (origin.x + layout_point.column as f32 * dimensions.cell_width).floor(), + origin.y + layout_point.line as f32 * dimensions.line_height, + ) + }; + let size = point( + (dimensions.cell_width * self.num_of_cells as f32).ceil(), + dimensions.line_height, + ) + .into(); + + window.paint_quad(fill(Bounds::new(position, size), self.color)); + } +} + +/// Represents a rectangular region with a specific color on a logical grid. +#[derive(Debug, Clone)] +struct BackgroundRegion { + start_line: i32, + start_col: i32, + end_line: i32, + end_col: i32, + color: Hsla, +} + +impl BackgroundRegion { + fn new(line: i32, col: i32, color: Hsla) -> Self { + BackgroundRegion { + start_line: line, + start_col: col, + end_line: line, + end_col: col, + color, + } + } + + fn with_extents( + start_line: i32, + start_col: i32, + end_line: i32, + end_col: i32, + color: Hsla, + ) -> Self { + BackgroundRegion { + start_line, + start_col, + end_line, + end_col, + color, + } + } + + /// Check if this region can be merged with another region + fn can_merge_with(&self, other: &BackgroundRegion) -> bool { + if self.color != other.color { + return false; + } + + // Check if regions are adjacent horizontally + if self.start_line == other.start_line && self.end_line == other.end_line { + return self.end_col + 1 == other.start_col || other.end_col + 1 == self.start_col; + } + + // Check if regions are adjacent vertically with same column span + if self.start_col == other.start_col && self.end_col == other.end_col { + return self.end_line + 1 == other.start_line || other.end_line + 1 == self.start_line; + } + + false + } + + /// Merge this region with another region + fn merge_with(&mut self, other: &BackgroundRegion) { + self.start_line = self.start_line.min(other.start_line); + self.start_col = self.start_col.min(other.start_col); + self.end_line = self.end_line.max(other.end_line); + self.end_col = self.end_col.max(other.end_col); + } +} + +pub trait TerminalLayoutCell { + fn point(&self) -> Point; + fn cell(&self) -> &Cell; +} + +impl TerminalLayoutCell for IndexedCell { + fn point(&self) -> Point { + self.point + } + + fn cell(&self) -> &Cell { + &self.cell + } +} + +impl TerminalLayoutCell for &IndexedCell { + fn point(&self) -> Point { + self.point + } + + fn cell(&self) -> &Cell { + &self.cell + } +} + +/// Merge grid regions to minimize the number of rectangles. +fn merge_background_regions(regions: Vec) -> Vec { + if regions.is_empty() { + return regions; + } + + let mut merged = regions; + let mut changed = true; + + // Keep merging until no more merges are possible + while changed { + changed = false; + let mut i = 0; + + while i < merged.len() { + let mut j = i + 1; + while j < merged.len() { + if merged[i].can_merge_with(&merged[j]) { + let other = merged.remove(j); + merged[i].merge_with(&other); + changed = true; + } else { + j += 1; + } + } + i += 1; + } + } + + merged +} + +/// The GPUI element that paints the terminal. +/// We need to keep a reference to the model for mouse events, do we need it for any other terminal stuff, or can we move that to connection? +pub struct TerminalElement { + terminal: Entity, + terminal_view: Entity, + workspace: WeakEntity, + focus: FocusHandle, + focused: bool, + cursor_visible: bool, + interactivity: Interactivity, + mode: TerminalMode, + block_below_cursor: Option>, +} + +impl InteractiveElement for TerminalElement { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + +impl StatefulInteractiveElement for TerminalElement {} + +impl TerminalElement { + pub fn new( + terminal: Entity, + terminal_view: Entity, + workspace: WeakEntity, + focus: FocusHandle, + focused: bool, + cursor_visible: bool, + block_below_cursor: Option>, + mode: TerminalMode, + ) -> TerminalElement { + TerminalElement { + terminal, + terminal_view, + workspace, + focused, + focus: focus.clone(), + cursor_visible, + block_below_cursor, + mode, + interactivity: Default::default(), + } + .track_focus(&focus) + } + + pub fn layout_grid( + grid: impl Iterator, + start_line_offset: i32, + text_style: &TextStyle, + hyperlink: Option<(HighlightStyle, &Range)>, + minimum_contrast: f32, + cx: &App, + ) -> ( + Vec, + Vec, + Vec, + ) { + let start_time = Instant::now(); + let theme = cx.theme(); + + // Pre-allocate with estimated capacity to reduce reallocations + let estimated_cells = grid.size_hint().0; + let estimated_runs = estimated_cells / 10; // Estimate ~10 cells per run + let estimated_regions = estimated_cells / 20; // Estimate ~20 cells per background region + + let mut batched_runs = Vec::with_capacity(estimated_runs); + let mut block_element_regions = Vec::new(); + let mut cell_count = 0; + + // Collect background regions for efficient merging + let mut background_regions: Vec = Vec::with_capacity(estimated_regions); + let mut current_batch: Option = None; + + // First pass: collect all cells and their backgrounds + let linegroups = grid.into_iter().chunk_by(|cell| cell.point().line); + for (line_index, (_, line)) in linegroups.into_iter().enumerate() { + let display_line = start_line_offset + line_index as i32; + + // Flush any existing batch at line boundaries + if let Some(batch) = current_batch.take() { + batched_runs.push(batch); + } + + let mut previous_cell_had_extras = false; + + for cell in line { + let point = cell.point(); + let cell = cell.cell(); + let mut fg = cell.foreground(); + let mut bg = cell.background(); + if cell.is_inverse() { + mem::swap(&mut fg, &mut bg); + } + + // Collect background regions (skip default background) + if !is_default_background_color(bg) { + let color = convert_color(&bg, theme); + let col = point.column as i32; + + // Try to extend the last region if it's on the same line with the same color + if let Some(last_region) = background_regions.last_mut() + && last_region.color == color + && last_region.start_line == display_line + && last_region.end_line == display_line + && last_region.end_col + 1 == col + { + last_region.end_col = col; + } else { + background_regions.push(BackgroundRegion::new(display_line, col, color)); + } + } + // Skip wide character spacers - they're just placeholders for the second cell of wide characters + if cell.is_wide_char_spacer() { + continue; + } + + // Skip spaces that follow cells with extras (emoji variation sequences) + if cell.character() == ' ' && previous_cell_had_extras { + previous_cell_had_extras = false; + continue; + } + // Update tracking for next iteration + previous_cell_had_extras = + matches!(cell.zerowidth(), Some(chars) if !chars.is_empty()); + + //Layout current cell text + { + if !is_blank(cell) { + cell_count += 1; + let cell_style = TerminalElement::cell_style( + point, + cell, + fg, + bg, + theme, + text_style, + hyperlink, + minimum_contrast, + ); + + let cell_point = LayoutPoint::new(display_line, point.column as i32); + if Self::collect_block_element_regions( + cell_point, + cell.character(), + cell_style.color, + &mut block_element_regions, + ) { + if let Some(batch) = current_batch.take() { + batched_runs.push(batch); + } + continue; + } + + let zero_width_chars = cell.zerowidth(); + + // Try to batch with existing run + if let Some(ref mut batch) = current_batch { + if batch.can_append(&cell_style) + && batch.start_point.line == cell_point.line + && batch.start_point.column + batch.cell_count as i32 + == cell_point.column + { + batch.append_char(cell.character()); + if let Some(chars) = zero_width_chars { + batch.append_zero_width_chars(chars); + } + } else { + // Flush current batch and start new one + let old_batch = current_batch.take().unwrap(); + batched_runs.push(old_batch); + let mut new_batch = BatchedTextRun::new_from_char( + cell_point, + cell.character(), + cell_style, + text_style.font_size, + ); + if let Some(chars) = zero_width_chars { + new_batch.append_zero_width_chars(chars); + } + current_batch = Some(new_batch); + } + } else { + // Start new batch + let mut new_batch = BatchedTextRun::new_from_char( + cell_point, + cell.character(), + cell_style, + text_style.font_size, + ); + if let Some(chars) = zero_width_chars { + new_batch.append_zero_width_chars(chars); + } + current_batch = Some(new_batch); + } + }; + } + } + } + + // Flush any remaining batch + if let Some(batch) = current_batch { + batched_runs.push(batch); + } + + // Second pass: merge background regions and convert to layout rects + let region_count = background_regions.len(); + let merged_regions = merge_background_regions(background_regions); + let mut rects = Vec::with_capacity(merged_regions.len() * 2); // Estimate 2 rects per merged region + + // Convert merged regions to layout rects + // Since LayoutRect only supports single-line rectangles, we need to split multi-line regions + for region in merged_regions { + for line in region.start_line..=region.end_line { + rects.push(LayoutRect::new( + LayoutPoint::new(line, region.start_col), + (region.end_col - region.start_col + 1) as usize, + region.color, + )); + } + } + + let block_element_region_count = block_element_regions.len(); + let block_element_rects = Self::block_element_regions_to_rects(block_element_regions); + let layout_time = start_time.elapsed(); + + log::debug!( + "Terminal layout_grid: {} cells processed, \ + {} batched runs created, {} block element rects (from {} regions), {} rects (from {} merged regions), \ + layout took {:?}", + cell_count, + batched_runs.len(), + block_element_rects.len(), + block_element_region_count, + rects.len(), + region_count, + layout_time + ); + + (rects, batched_runs, block_element_rects) + } + + /// Computes the cursor position based on the cursor point and terminal dimensions. + fn cursor_position( + cursor_point: DisplayCursor, + size: TerminalBounds, + ) -> Option> { + if cursor_point.line() < size.num_lines() as i32 { + // When on pixel boundaries round the origin down + Some(point( + (cursor_point.col() as f32 * size.cell_width()).floor(), + (cursor_point.line() as f32 * size.line_height()).floor(), + )) + } else { + None + } + } + + /// Checks if a character is a decorative block/box-like character that should + /// preserve its exact colors without contrast adjustment. + /// + /// This specifically targets characters used as visual connectors, separators, + /// and borders where color matching with adjacent backgrounds is critical. + /// Regular icons (git, folders, etc.) are excluded as they need to remain readable. + /// + /// Fixes https://github.com/zed-industries/zed/issues/34234 + fn is_decorative_character(ch: char) -> bool { + matches!( + ch as u32, + // Unicode Box Drawing and Block Elements + 0x2500..=0x257F // Box Drawing (└ ┐ ─ │ etc.) + | 0x2580..=0x259F // Block Elements (▀ ▄ █ ░ ▒ ▓ etc.) + | 0x25A0..=0x25FF // Geometric Shapes (■ ▶ ● etc. - includes triangular/circular separators) + | 0x1FB00..=0x1FB3B // Symbols for Legacy Computing sextants used by terminal QR renderers + + // Private Use Area - Powerline separator symbols only + | 0xE0B0..=0xE0B7 // Powerline separators: triangles (E0B0-E0B3) and half circles (E0B4-E0B7) + | 0xE0B8..=0xE0BF // Powerline separators: corner triangles + | 0xE0C0..=0xE0CA // Powerline separators: flames (E0C0-E0C3), pixelated (E0C4-E0C7), and ice (E0C8 & E0CA) + | 0xE0CC..=0xE0D1 // Powerline separators: honeycombs (E0CC-E0CD) and lego (E0CE-E0D1) + | 0xE0D2..=0xE0D7 // Powerline separators: trapezoid (E0D2 & E0D4) and inverted triangles (E0D6-E0D7) + ) + } + + /// Whether the application explicitly picked this foreground color and does not + /// want it adjusted for contrast: 24-bit true color (`\e[38;2;R;G;Bm`) or a + /// specific entry in the 256-color palette (`\e[38;5;Nm`) where N >= 16 (the + /// 6x6x6 cube at 16..=231 and the 24-step grayscale ramp at 232..=255). + /// Indices 0..=15 still go through contrast adjustment since those map to + /// theme-defined ANSI colors that can clash with the theme background. + fn is_app_chosen_exact_color(fg: &Color) -> bool { + terminal_is_app_chosen_exact_color(*fg) + } + + /// Returns the filled subcells of a sextant character as a bitmap, where + /// bit `row * 2 + column` is set when that 2x3 subcell is filled. + /// + /// U+1FB00..=U+1FB3B enumerate all 2x3 fill combinations except the four + /// that already exist as Block Elements (empty, `▌` = 0b010101, + /// `▐` = 0b101010, and `█` = 0b111111), hence the gap adjustments. + fn sextant_char_to_filled_bits(ch: char) -> Option { + let offset = (ch as u32).checked_sub(0x1FB00)?; + if offset > 0x3B { + return None; + } + + Some((offset + 1 + u32::from(offset >= 20) + u32::from(offset >= 40)) as u8) + } + + /// Returns the filled quadrants of a quadrant character as a bitmap, where + /// bit `row * 2 + column` is set when that 2x2 subcell is filled. + fn quadrant_char_to_filled_bits(ch: char) -> Option { + Some(match ch { + '▘' => 0b0001, + '▝' => 0b0010, + '▖' => 0b0100, + '▗' => 0b1000, + '▚' => 0b1001, + '▞' => 0b0110, + '▛' => 0b0111, + '▜' => 0b1011, + '▙' => 0b1101, + '▟' => 0b1110, + _ => return None, + }) + } + + /// Returns `(column, line, num_of_columns, num_of_lines)` in subcell units + /// for block element characters that consist of a single rectangle. + fn block_char_to_rect(ch: char) -> Option<(i32, i32, i32, i32)> { + let codepoint = ch as u32; + Some(match codepoint { + // ▀ upper half + 0x2580 => (0, 0, 8, 12), + // ▁▂▃▄▅▆▇█ lower blocks of 1..=8 eighths + 0x2581..=0x2588 => { + let eighths = (codepoint - 0x2580) as i32; + (0, 24 - eighths * 3, 8, eighths * 3) + } + // ▉▊▋▌▍▎▏ left blocks of 7..=1 eighths + 0x2589..=0x258F => (0, 0, (0x2590 - codepoint) as i32, 24), + // ▐ right half + 0x2590 => (4, 0, 4, 24), + // ▔ upper eighth + 0x2594 => (0, 0, 8, 3), + // ▕ right eighth + 0x2595 => (7, 0, 1, 24), + _ => return None, + }) + } + + /// Approximates the shade characters `░▒▓` with the foreground color at + /// reduced opacity instead of the stipple patterns fonts use, trading + /// pattern fidelity for seamless cell coverage. + fn shade_char_to_opacity(ch: char) -> Option { + match ch { + '░' => Some(0.25), + '▒' => Some(0.5), + '▓' => Some(0.75), + _ => None, + } + } + + fn collect_block_element_regions( + point: LayoutPoint, + ch: char, + color: Hsla, + regions: &mut Vec, + ) -> bool { + if let Some((column, line, num_of_columns, num_of_lines)) = Self::block_char_to_rect(ch) { + Self::push_block_element_region( + point, + column, + line, + num_of_columns, + num_of_lines, + color, + regions, + ); + return true; + } + + if let Some(filled) = Self::quadrant_char_to_filled_bits(ch) { + for row in 0..2 { + for column in 0..2 { + if filled & (1 << (row * 2 + column)) != 0 { + Self::push_block_element_region( + point, + column * 4, + row * 12, + 4, + 12, + color, + regions, + ); + } + } + } + return true; + } + + if let Some(filled) = Self::sextant_char_to_filled_bits(ch) { + for row in 0..3 { + for column in 0..2 { + if filled & (1 << (row * 2 + column)) != 0 { + Self::push_block_element_region( + point, + column * 4, + row * 8, + 4, + 8, + color, + regions, + ); + } + } + } + return true; + } + + if let Some(opacity) = Self::shade_char_to_opacity(ch) { + Self::push_block_element_region(point, 0, 0, 8, 24, color.opacity(opacity), regions); + return true; + } + + false + } + + fn push_block_element_region( + point: LayoutPoint, + column: i32, + line: i32, + num_of_columns: i32, + num_of_lines: i32, + color: Hsla, + regions: &mut Vec, + ) { + let start_line = point.line * BLOCK_SUBCELL_LINES + line; + let start_col = point.column * BLOCK_SUBCELL_COLUMNS + column; + let end_line = start_line + num_of_lines - 1; + let end_col = start_col + num_of_columns - 1; + + // Extend the previous region when possible (e.g. runs of `█` in a QR + // code) to keep the quadratic merge pass over a small input. + if let Some(last_region) = regions.last_mut() + && last_region.color == color + && last_region.start_line == start_line + && last_region.end_line == end_line + && last_region.end_col + 1 == start_col + { + last_region.end_col = end_col; + return; + } + + regions.push(BackgroundRegion::with_extents( + start_line, start_col, end_line, end_col, color, + )); + } + + fn block_element_regions_to_rects( + regions: Vec, + ) -> Vec { + merge_background_regions(regions) + .into_iter() + .map(|region| { + BlockElementLayoutRect::new( + LayoutPoint::new(region.start_line, region.start_col), + (region.end_col - region.start_col + 1) as usize, + (region.end_line - region.start_line + 1) as usize, + region.color, + ) + }) + .collect() + } + + /// Converts the Alacritty cell styles to GPUI text styles and background color. + fn cell_style( + point: Point, + cell: &Cell, + fg: Color, + bg: Color, + colors: &Theme, + text_style: &TextStyle, + hyperlink: Option<(HighlightStyle, &Range)>, + minimum_contrast: f32, + ) -> TextRun { + let skip_contrast = Self::is_app_chosen_exact_color(&fg); + let mut fg = convert_color(&fg, colors); + let bg = convert_color(&bg, colors); + + if !skip_contrast && !Self::is_decorative_character(cell.character()) { + fg = ensure_minimum_contrast(fg, bg, minimum_contrast); + } + + // Use a dim multiplier that stays close to the existing Alacritty look. + if cell.is_dim() { + fg.a *= 0.7; + } + + let underline = + (cell.has_underline() || cell.hyperlink().is_some()).then(|| UnderlineStyle { + color: Some(fg), + thickness: Pixels::from(1.0), + wavy: cell.has_undercurl(), + }); + + let strikethrough = cell.has_strikeout().then(|| StrikethroughStyle { + color: Some(fg), + thickness: Pixels::from(1.0), + }); + + let weight = if cell.is_bold() { + FontWeight::BOLD + } else { + text_style.font_weight + }; + + let style = if cell.is_italic() { + FontStyle::Italic + } else { + FontStyle::Normal + }; + + let mut result = TextRun { + len: cell.character().len_utf8(), + color: fg, + background_color: None, + font: Font { + weight, + style, + ..text_style.font() + }, + underline, + strikethrough, + }; + + if let Some((style, range)) = hyperlink + && range.contains(point) + { + if let Some(underline) = style.underline { + result.underline = Some(underline); + } + + if let Some(color) = style.color { + result.color = color; + } + } + + result + } + + fn generic_button_handler( + connection: Entity, + focus_handle: FocusHandle, + steal_focus: bool, + f: impl Fn(&mut Terminal, &E, &mut Context), + ) -> impl Fn(&E, &mut Window, &mut App) { + move |event, window, cx| { + if steal_focus { + window.focus(&focus_handle, cx); + } else if !focus_handle.is_focused(window) { + return; + } + connection.update(cx, |terminal, cx| { + f(terminal, event, cx); + + cx.notify(); + }) + } + } + + fn register_mouse_listeners( + &mut self, + mode: Modes, + hitbox: &Hitbox, + content_mode: &ContentMode, + window: &mut Window, + ) { + let focus = self.focus.clone(); + let terminal = self.terminal.clone(); + let terminal_view = self.terminal_view.clone(); + + self.interactivity.on_mouse_down(MouseButton::Left, { + let terminal = terminal.clone(); + let focus = focus.clone(); + let terminal_view = terminal_view.clone(); + + move |e, window, cx| { + window.focus(&focus, cx); + + let scroll_top = terminal_view.read(cx).scroll_top; + terminal.update(cx, |terminal, cx| { + let mut adjusted_event = e.clone(); + if scroll_top > Pixels::ZERO { + adjusted_event.position.y += scroll_top; + } + terminal.mouse_down(&adjusted_event, cx); + cx.notify(); + }) + } + }); + + window.on_mouse_event({ + let terminal = self.terminal.clone(); + let hitbox = hitbox.clone(); + let focus = focus.clone(); + let terminal_view = terminal_view; + move |e: &MouseMoveEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + if e.pressed_button.is_some() && !cx.has_active_drag() && focus.is_focused(window) { + let hovered = hitbox.is_hovered(window); + + let scroll_top = terminal_view.read(cx).scroll_top; + terminal.update(cx, |terminal, cx| { + if terminal.selection_started() || hovered { + let mut adjusted_event = e.clone(); + if scroll_top > Pixels::ZERO { + adjusted_event.position.y += scroll_top; + } + terminal.mouse_drag(&adjusted_event, hitbox.bounds, cx); + cx.notify(); + } + }) + } + + if hitbox.is_hovered(window) { + terminal.update(cx, |terminal, cx| { + terminal.mouse_move(e, cx); + }) + } + } + }); + + self.interactivity.on_mouse_up( + MouseButton::Left, + TerminalElement::generic_button_handler( + terminal.clone(), + focus.clone(), + false, + move |terminal, e, cx| { + terminal.mouse_up(e, cx); + }, + ), + ); + self.interactivity.on_mouse_down( + MouseButton::Middle, + TerminalElement::generic_button_handler( + terminal.clone(), + focus.clone(), + true, + move |terminal, e, cx| { + terminal.mouse_down(e, cx); + }, + ), + ); + + if content_mode.is_scrollable() { + self.interactivity.on_scroll_wheel({ + let terminal_view = self.terminal_view.downgrade(); + move |e, window, cx| { + terminal_view + .update(cx, |terminal_view, cx| { + if matches!(terminal_view.mode, TerminalMode::Standalone) + || terminal_view.focus_handle.is_focused(window) + { + terminal_view.scroll_wheel(e, cx); + cx.notify(); + } + }) + .ok(); + } + }); + } + + // Mouse mode handlers: + // All mouse modes need the extra click handlers + if mode.intersects(Modes::MOUSE_MODE) { + self.interactivity.on_mouse_down( + MouseButton::Right, + TerminalElement::generic_button_handler( + terminal.clone(), + focus.clone(), + true, + move |terminal, e, cx| { + terminal.mouse_down(e, cx); + }, + ), + ); + self.interactivity.on_mouse_up( + MouseButton::Right, + TerminalElement::generic_button_handler( + terminal.clone(), + focus.clone(), + false, + move |terminal, e, cx| { + terminal.mouse_up(e, cx); + }, + ), + ); + self.interactivity.on_mouse_up( + MouseButton::Middle, + TerminalElement::generic_button_handler( + terminal, + focus, + false, + move |terminal, e, cx| { + terminal.mouse_up(e, cx); + }, + ), + ); + } + } + + fn rem_size(&self, cx: &mut App) -> Option { + let settings = ThemeSettings::get_global(cx).clone(); + let buffer_font_size = settings.buffer_font_size(cx); + let rem_size_scale = { + // Our default UI font size is 14px on a 16px base scale. + // This means the default UI font size is 0.875rems. + let default_font_size_scale = 14. / ui::BASE_REM_SIZE_IN_PX; + + // We then determine the delta between a single rem and the default font + // size scale. + let default_font_size_delta = 1. - default_font_size_scale; + + // Finally, we add this delta to 1rem to get the scale factor that + // should be used to scale up the UI. + 1. + default_font_size_delta + }; + + Some(buffer_font_size * rem_size_scale) + } +} + +impl Element for TerminalElement { + type RequestLayoutState = (); + type PrepaintState = LayoutState; + + fn id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let height: Length = match self.terminal_view.read(cx).content_mode(window, cx) { + ContentMode::Inline { + displayed_lines, + total_lines: _, + } => { + let rem_size = window.rem_size(); + let line_height = f32::from(window.text_style().font_size.to_pixels(rem_size)) + * TerminalSettings::get_global(cx).line_height.value(); + // Round up to a whole device pixel to prevent pixel snapping from rounding down, + // which would result in the terminal being one row short after flooring. + let scale_factor = window.scale_factor().max(1.); + let height = displayed_lines as f32 * line_height; + px((height * scale_factor).ceil() / scale_factor).into() + } + ContentMode::Scrollable => { + if let TerminalMode::Embedded { .. } = &self.mode { + let term = self.terminal.read(cx); + if !term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused { + self.interactivity.occlude_mouse(); + } + } + + relative(1.).into() + } + }; + + let layout_id = self.interactivity.request_layout( + global_id, + inspector_id, + window, + cx, + |mut style, window, cx| { + style.size.width = relative(1.).into(); + style.size.height = height; + + window.request_layout(style, None, cx) + }, + ); + (layout_id, ()) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let rem_size = self.rem_size(cx); + self.interactivity.prepaint( + global_id, + inspector_id, + bounds, + bounds.size, + window, + cx, + |_, _, hitbox, window, cx| { + let hitbox = hitbox.unwrap(); + let settings = ThemeSettings::get_global(cx).clone(); + + let buffer_font_size = settings.buffer_font_size(cx); + + let terminal_settings = TerminalSettings::get_global(cx); + let minimum_contrast = terminal_settings.minimum_contrast; + + let font_family = terminal_settings.font_family.as_ref().map_or_else( + || settings.buffer_font.family.clone(), + |font_family| font_family.0.clone().into(), + ); + + let font_fallbacks = terminal_settings + .font_fallbacks + .as_ref() + .or(settings.buffer_font.fallbacks.as_ref()) + .cloned(); + + let font_features = terminal_settings + .font_features + .as_ref() + .unwrap_or(&FontFeatures::disable_ligatures()) + .clone(); + + let font_weight = terminal_settings.font_weight.unwrap_or_default(); + + let line_height = terminal_settings.line_height.value(); + + let font_size = match &self.mode { + TerminalMode::Embedded { .. } => { + window.text_style().font_size.to_pixels(window.rem_size()) + } + TerminalMode::Standalone => terminal_settings + .font_size + .map_or(buffer_font_size, |size| { + theme_settings::adjusted_font_size(size, cx) + }), + }; + + let theme = cx.theme().clone(); + + let link_style = HighlightStyle { + color: Some(theme.colors().link_text_hover), + font_weight: Some(font_weight), + font_style: None, + background_color: None, + underline: Some(UnderlineStyle { + thickness: px(1.0), + color: Some(theme.colors().link_text_hover), + wavy: false, + }), + strikethrough: None, + fade_out: None, + }; + + let text_style = TextStyle { + font_family, + font_features, + font_weight, + font_fallbacks, + font_size: font_size.into(), + font_style: FontStyle::Normal, + line_height: px(line_height).into(), + background_color: Some(theme.colors().terminal_ansi_background), + white_space: WhiteSpace::Normal, + // These are going to be overridden per-cell + color: theme.colors().terminal_foreground, + ..Default::default() + }; + + let text_system = cx.text_system(); + let player_color = theme.players().local(); + let match_color = theme.colors().search_match_background; + let gutter; + let (dimensions, line_height_px) = { + let rem_size = window.rem_size(); + let font_pixels = text_style.font_size.to_pixels(rem_size); + let line_height = f32::from(font_pixels) * line_height; + let font_id = cx.text_system().resolve_font(&text_style.font()); + + let cell_width = text_system + .advance(font_id, font_pixels, 'm') + .unwrap() + .width; + gutter = cell_width; + + let mut size = bounds.size; + size.width -= gutter; + let available_height = size.height; + + // https://github.com/zed-industries/zed/issues/2750 + // if the terminal is one column wide, rendering 🦀 + // causes alacritty to misbehave. + if size.width < cell_width * 2.0 { + size.width = cell_width * 2.0; + } + + let mut origin = bounds.origin; + origin.x += gutter; + + if matches!(self.terminal_view.read(cx).mode, TerminalMode::Standalone) { + let should_anchor_to_bottom = { + let terminal_view = self.terminal_view.read(cx); + let content = self.terminal.read(cx).last_content(); + terminal_view.vertical_alignment + == TerminalVerticalAlignment::BottomWhenFull + && (content.mode.contains(Modes::ALT_SCREEN) + || (content.scrolled_to_bottom && content.bottom_row_occupied)) + }; + let scale_factor = window.scale_factor(); + let line_height_pixels = px(line_height); + let line_height_device_px = (f32::from(line_height_pixels) * scale_factor) + .round() + .max(1.0) as i32; + let available_height_device_px = + (f32::from(available_height) * scale_factor) + .floor() + .max(0.0) as i32; + + let rows = + ((available_height_device_px / line_height_device_px) as usize).max(1); + let snapped_height_device_px = (rows as i32) * line_height_device_px; + let padding_device_px = + (available_height_device_px - snapped_height_device_px).max(0); + + let snapped_height = + px(snapped_height_device_px as f32 / scale_factor.max(1.0)); + let padding = px(padding_device_px as f32 / scale_factor.max(1.0)); + + size.height = snapped_height; + if should_anchor_to_bottom { + origin.y += padding; + } + } + + // Snap to device pixels to avoid subpixel jitter while resizing. + // Terminal rendering is grid-based; allowing fractional origins can cause the + // glyph rasterization to shift between frames, which looks like flicker. + let scale_factor = window.scale_factor(); + let snap_px = |value: Pixels| { + Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor) + }; + origin.x = snap_px(origin.x); + origin.y = snap_px(origin.y); + + ( + TerminalBounds::new(px(line_height), cell_width, Bounds { origin, size }), + line_height, + ) + }; + + let search_matches = self.terminal.read(cx).matches.clone(); + + let background_color = theme.colors().terminal_background; + + let (hover_tooltip, hover_match) = self.terminal.update(cx, |terminal, cx| { + terminal.set_size(dimensions); + terminal.sync(window, cx); + + if window.modifiers().secondary() + && bounds.contains(&window.mouse_position()) + && let Some(registered_hover) = self.terminal_view.read(cx).hover.as_ref() + { + if let Some(last_hovered_word) = + terminal.last_content.last_hovered_word.as_ref() + && registered_hover.hovered_word.id == last_hovered_word.id + { + ( + Some(registered_hover.tooltip.clone()), + Some(last_hovered_word.word_match), + ) + } else { + (None, None) + } + } else { + (None, None) + } + }); + + let scroll_top = self.terminal_view.read(cx).scroll_top; + let hyperlink_tooltip = hover_tooltip.map(|hover_tooltip| { + let offset = dimensions.bounds.origin - point(px(0.), scroll_top); + let mut element = div() + .size_full() + .id("terminal-element") + .tooltip(Tooltip::text(hover_tooltip)) + .into_any_element(); + element.prepaint_as_root(offset, bounds.size.into(), window, cx); + element + }); + + let Content { + cells, + mode, + display_offset, + cursor_char, + selection, + cursor, + .. + } = &self.terminal.read(cx).last_content; + let mode = *mode; + let display_offset = *display_offset; + + // searches, highlights to a single range representations + let mut relative_highlighted_ranges = Vec::new(); + for search_match in search_matches { + relative_highlighted_ranges.push((search_match, match_color)) + } + if let Some(selection) = selection { + relative_highlighted_ranges + .push((selection.point_range(), player_color.selection)); + } + + // then have that representation be converted to the appropriate highlight data structure + + let content_mode = self.terminal_view.read(cx).content_mode(window, cx); + + // Calculate the intersection of the terminal's bounds with the current + // content mask (the visible viewport after all parent clipping). + // This allows us to only render cells that are actually visible, which is + // critical for performance when terminals are inside scrollable containers + // like the Agent Panel thread view. + // + // This optimization is analogous to the editor optimization in PR #45077 + // which fixed performance issues with large AutoHeight editors inside Lists. + let content_bounds = dimensions.bounds; + let visible_bounds = window.content_mask().bounds; + let intersection = visible_bounds.intersect(&content_bounds); + + // If the terminal is entirely outside the viewport, skip all cell processing. + // This handles the case where the terminal has been scrolled past (above or + // below the viewport), similar to the editor fix in PR #45077 where start_row + // could exceed max_row when the editor was positioned above the viewport. + let (rects, batched_text_runs, block_element_rects) = if intersection.size.height + <= px(0.) + || intersection.size.width <= px(0.) + { + (Vec::new(), Vec::new(), Vec::new()) + } else if intersection == content_bounds { + // Fast path: terminal fully visible, no clipping needed. + // Avoid grouping/allocation overhead by streaming cells directly. + TerminalElement::layout_grid( + cells.iter(), + 0, + &text_style, + hover_match + .as_ref() + .map(|hover_match| (link_style, hover_match)), + minimum_contrast, + cx, + ) + } else { + // Calculate which screen rows are visible based on pixel positions. + // This works for both Scrollable and Inline modes because we filter + // by screen position (enumerated line group index), not by the cell's + // internal line number (which can be negative in Scrollable mode for + // scrollback history). + let rows_above_viewport = f32::from( + (intersection.top() - content_bounds.top()).max(px(0.)) / line_height_px, + ) as usize; + let visible_row_count = + f32::from((intersection.size.height / line_height_px).ceil()) as usize + 1; + + TerminalElement::layout_grid( + // Group cells by line and filter to only the visible screen rows. + // skip() and take() work on enumerated line groups (screen position), + // making this work regardless of the actual cell.point.line values. + cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_above_viewport) + .take(visible_row_count) + .flat_map(|(_, line_cells)| line_cells), + rows_above_viewport as i32, + &text_style, + hover_match + .as_ref() + .map(|hover_match| (link_style, hover_match)), + minimum_contrast, + cx, + ) + }; + + // Layout cursor. Rectangle is used for IME, so we should lay it out even + // if we don't end up showing it. + let cursor_point = DisplayCursor::from(cursor.point, display_offset); + let cursor_text = { + let str_trxt = cursor_char.to_string(); + let len = str_trxt.len(); + window.text_system().shape_line( + str_trxt.into(), + text_style.font_size.to_pixels(window.rem_size()), + &[TextRun { + len, + font: text_style.font(), + color: theme.colors().terminal_ansi_background, + ..Default::default() + }], + None, + ) + }; + + // For whitespace, use cell width to avoid cursor stretching. + // For other characters, use the larger of shaped width and cell width + // to properly cover wide characters like emojis. + let cursor_width = if cursor_char.is_whitespace() { + dimensions.cell_width() + } else { + cursor_text.width.max(dimensions.cell_width()) + }; + + let ime_cursor_bounds = TerminalElement::cursor_position(cursor_point, dimensions) + .map(|cursor_position| Bounds { + origin: cursor_position, + size: size(cursor_width.ceil(), dimensions.line_height), + }); + + let cursor = if let CursorShape::Hidden = cursor.shape { + None + } else { + let focused = self.focused; + ime_cursor_bounds.map(move |bounds| { + let (shape, text) = match cursor.shape { + CursorShape::Block if !focused => (EditorCursorShape::Hollow, None), + CursorShape::Block => (EditorCursorShape::Block, Some(cursor_text)), + CursorShape::Underline if !focused => (EditorCursorShape::Hollow, None), + CursorShape::Underline => (EditorCursorShape::Underline, None), + CursorShape::Bar if !focused => (EditorCursorShape::Hollow, None), + CursorShape::Bar => (EditorCursorShape::Bar, None), + CursorShape::HollowBlock => (EditorCursorShape::Hollow, None), + CursorShape::Hidden => unreachable!(), + }; + + CursorLayout::new( + bounds.origin, + bounds.size.width, + bounds.size.height, + theme.players().local().cursor, + shape, + text, + ) + }) + }; + + let block_below_cursor_element = if let Some(block) = &self.block_below_cursor { + let terminal = self.terminal.read(cx); + if terminal.last_content.display_offset == 0 { + let target_line = terminal.last_content.cursor.point.line + 1; + let render = &block.render; + let mut block_cx = BlockContext { + window, + context: cx, + dimensions, + }; + let element = render(&mut block_cx); + let mut element = div().occlude().child(element).into_any_element(); + let available_space = size( + AvailableSpace::Definite(dimensions.width() + gutter), + AvailableSpace::Definite( + block.height as f32 * dimensions.line_height(), + ), + ); + let origin = GpuiPoint::new(bounds.origin.x, dimensions.bounds.origin.y) + + point(px(0.), target_line as f32 * dimensions.line_height()) + - point(px(0.), scroll_top); + window.with_rem_size(rem_size, |window| { + element.prepaint_as_root(origin, available_space, window, cx); + }); + Some(element) + } else { + None + } + } else { + None + }; + + LayoutState { + hitbox, + batched_text_runs, + block_element_rects, + cursor, + ime_cursor_bounds, + background_color, + dimensions, + rects, + relative_highlighted_ranges, + mode, + display_offset, + hyperlink_tooltip, + block_below_cursor_element, + base_text_style: text_style, + content_mode, + } + }, + ) + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + layout: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let paint_start = Instant::now(); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + let scroll_top = self.terminal_view.read(cx).scroll_top; + + window.paint_quad(fill(bounds, layout.background_color)); + let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top); + let scale_factor = window.scale_factor(); + let snap_px = |value: Pixels| { + Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor) + }; + let origin = point(snap_px(origin.x), snap_px(origin.y)); + + let marked_text_cloned: Option = { + let ime_state = &self.terminal_view.read(cx).ime_state; + ime_state.as_ref().map(|state| state.marked_text.clone()) + }; + + let terminal_input_handler = TerminalInputHandler { + terminal_view: self.terminal_view.clone(), + cursor_bounds: layout.ime_cursor_bounds.map(|bounds| bounds + origin), + workspace: self.workspace.clone(), + }; + + self.register_mouse_listeners( + layout.mode, + &layout.hitbox, + &layout.content_mode, + window, + ); + if window.modifiers().secondary() + && bounds.contains(&window.mouse_position()) + && self.terminal_view.read(cx).hover.is_some() + { + window.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox); + } else { + window.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox); + } + + let original_cursor = layout.cursor.take(); + let hyperlink_tooltip = layout.hyperlink_tooltip.take(); + let block_below_cursor_element = layout.block_below_cursor_element.take(); + self.interactivity.paint( + global_id, + inspector_id, + bounds, + Some(&layout.hitbox), + window, + cx, + |_, window, cx| { + window.handle_input(&self.focus, terminal_input_handler, cx); + + window.on_key_event({ + let this = self.terminal.clone(); + move |event: &ModifiersChangedEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + this.update(cx, |term, cx| { + term.try_modifiers_change(&event.modifiers, window, cx) + }); + } + }); + + for rect in &layout.rects { + rect.paint(origin, &layout.dimensions, window); + } + + for (relative_highlighted_range, color) in &layout.relative_highlighted_ranges { + if let Some((start_y, highlighted_range_lines)) = + to_highlighted_range_lines(relative_highlighted_range, layout, origin) + { + let corner_radius = if EditorSettings::get_global(cx).rounded_selection + { + 0.15 * layout.dimensions.line_height + } else { + Pixels::ZERO + }; + let hr = HighlightedRange { + start_y, + line_height: layout.dimensions.line_height, + lines: highlighted_range_lines, + color: *color, + corner_radius: corner_radius, + }; + hr.paint(true, bounds, window); + } + } + + // Paint batched text runs instead of individual cells + let text_paint_start = Instant::now(); + for batch in &layout.batched_text_runs { + batch.paint(origin, &layout.dimensions, window, cx); + } + for block_element_rect in &layout.block_element_rects { + block_element_rect.paint(origin, &layout.dimensions, window); + } + let text_paint_time = text_paint_start.elapsed(); + + if let Some(text_to_mark) = &marked_text_cloned + && !text_to_mark.is_empty() + && let Some(ime_bounds) = layout.ime_cursor_bounds + { + let ime_position = (ime_bounds + origin).origin; + let mut ime_style = layout.base_text_style.clone(); + ime_style.underline = Some(UnderlineStyle { + color: Some(ime_style.color), + thickness: px(1.0), + wavy: false, + }); + + let shaped_line = window.text_system().shape_line( + text_to_mark.clone().into(), + ime_style.font_size.to_pixels(window.rem_size()), + &[TextRun { + len: text_to_mark.len(), + font: ime_style.font(), + color: ime_style.color, + underline: ime_style.underline, + ..Default::default() + }], + None, + ); + + // Paint background to cover terminal text behind marked text + let ime_background_bounds = Bounds::new( + ime_position, + size(shaped_line.width, layout.dimensions.line_height), + ); + window.paint_quad(fill(ime_background_bounds, layout.background_color)); + + shaped_line + .paint( + ime_position, + layout.dimensions.line_height, + gpui::TextAlign::Left, + None, + window, + cx, + ) + .log_err(); + } + + if self.cursor_visible + && marked_text_cloned.is_none() + && let Some(mut cursor) = original_cursor + { + cursor.paint(origin, window, cx); + } + + if let Some(mut element) = block_below_cursor_element { + element.paint(window, cx); + } + + if let Some(mut element) = hyperlink_tooltip { + element.paint(window, cx); + } + + log::debug!( + "Terminal paint: {} text runs, {} rects, \ + text paint took {:?}, total paint took {total_paint_time:?}", + layout.batched_text_runs.len(), + layout.rects.len(), + text_paint_time, + total_paint_time = paint_start.elapsed() + ); + }, + ); + }); + } +} + +impl IntoElement for TerminalElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct TerminalInputHandler { + terminal_view: Entity, + workspace: WeakEntity, + cursor_bounds: Option>, +} + +impl InputHandler for TerminalInputHandler { + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _: &mut Window, + _cx: &mut App, + ) -> Option { + // Always return a valid selection for IME positioning, + // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.) + // The terminal still has a cursor position that should be used for IME candidate window placement. + Some(UTF16Selection { + range: 0..0, + reversed: false, + }) + } + + fn marked_text_range( + &mut self, + _window: &mut Window, + cx: &mut App, + ) -> Option> { + self.terminal_view.read(cx).marked_text_range() + } + + fn text_for_range( + &mut self, + _: std::ops::Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + _replacement_range: Option>, + text: &str, + window: &mut Window, + cx: &mut App, + ) { + self.terminal_view.update(cx, |view, view_cx| { + view.clear_marked_text(view_cx); + view.commit_text(text, view_cx); + }); + + self.workspace + .update(cx, |this, cx| { + window.invalidate_character_coordinates(); + let project = this.project().read(cx); + let telemetry = project.client().telemetry().clone(); + telemetry.log_edit_event("terminal", project.is_via_remote_server()); + }) + .ok(); + } + + fn replace_and_mark_text_in_range( + &mut self, + _range_utf16: Option>, + new_text: &str, + _new_marked_range: Option>, + _window: &mut Window, + cx: &mut App, + ) { + self.terminal_view.update(cx, |view, view_cx| { + view.set_marked_text(new_text.to_string(), view_cx); + }); + } + + fn unmark_text(&mut self, _window: &mut Window, cx: &mut App) { + self.terminal_view.update(cx, |view, view_cx| { + view.clear_marked_text(view_cx); + }); + } + + fn bounds_for_range( + &mut self, + range_utf16: std::ops::Range, + _window: &mut Window, + cx: &mut App, + ) -> Option> { + let term_bounds = self.terminal_view.read(cx).terminal_bounds(cx); + + let mut bounds = self.cursor_bounds?; + let offset_x = term_bounds.cell_width * range_utf16.start as f32; + bounds.origin.x += offset_x; + + Some(bounds) + } + + fn apple_press_and_hold_enabled(&mut self) -> bool { + false + } + + fn character_index_for_point( + &mut self, + _point: GpuiPoint, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + None + } +} + +pub fn is_blank(cell: &Cell) -> bool { + if cell.character() != ' ' { + return false; + } + + if !is_default_background_color(cell.background()) { + return false; + } + + if cell.hyperlink().is_some() { + return false; + } + + if cell.has_visible_style_modifier() { + return false; + } + + true +} + +fn to_highlighted_range_lines( + range: &Range, + layout: &LayoutState, + origin: GpuiPoint, +) -> Option<(Pixels, Vec)> { + // Step 1. Normalize the points to be viewport relative. + // When display_offset = 1, here's how the grid is arranged: + //-2,0 -2,1... + //--- Viewport top + //-1,0 -1,1... + //--------- Terminal Top + // 0,0 0,1... + // 1,0 1,1... + //--- Viewport Bottom + // 2,0 2,1... + //--------- Terminal Bottom + + // Normalize to viewport relative, from terminal relative. + // lines are i32s, which are negative above the top left corner of the terminal + // If the user has scrolled, we use the display_offset to tell us which offset + // of the grid data we should be looking at. But for the rendering step, we don't + // want negatives. We want things relative to the 'viewport' (the area of the grid + // which is currently shown according to the display offset) + let display_offset = i32::try_from(layout.display_offset).unwrap_or(i32::MAX); + let unclamped_start_line = range.start().line.saturating_add(display_offset); + let unclamped_start_column = range.start().column; + let unclamped_end_line = range.end().line.saturating_add(display_offset); + let unclamped_end_column = range.end().column; + + // Step 2. Clamp range to viewport, and return None if it doesn't overlap + if unclamped_end_line < 0 || unclamped_start_line > layout.dimensions.num_lines() as i32 { + return None; + } + + let clamped_start_line = unclamped_start_line.max(0) as usize; + + let clamped_end_line = unclamped_end_line.min(layout.dimensions.num_lines() as i32) as usize; + + // Convert the start of the range to pixels + let start_y = origin.y + clamped_start_line as f32 * layout.dimensions.line_height; + + // Step 3. Expand ranges that cross lines into a collection of single-line ranges. + // (also convert to pixels) + let mut highlighted_range_lines = Vec::new(); + for line in clamped_start_line..=clamped_end_line { + let mut line_start = 0; + let mut line_end = layout.dimensions.num_columns(); + + if line == clamped_start_line && unclamped_start_line >= 0 { + line_start = unclamped_start_column; + } + if line == clamped_end_line && unclamped_end_line <= layout.dimensions.num_lines() as i32 { + line_end = unclamped_end_column + 1; // +1 for inclusive + } + + highlighted_range_lines.push(HighlightedRangeLine { + start_x: origin.x + line_start as f32 * layout.dimensions.cell_width, + end_x: origin.x + line_end as f32 * layout.dimensions.cell_width, + }); + } + + Some((start_y, highlighted_range_lines)) +} + +/// Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent. +pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla { + let colors = theme.colors(); + match fg { + // Named and theme defined colors + Color::Named(color) => match color { + NamedColor::Black => colors.terminal_ansi_black, + NamedColor::Red => colors.terminal_ansi_red, + NamedColor::Green => colors.terminal_ansi_green, + NamedColor::Yellow => colors.terminal_ansi_yellow, + NamedColor::Blue => colors.terminal_ansi_blue, + NamedColor::Magenta => colors.terminal_ansi_magenta, + NamedColor::Cyan => colors.terminal_ansi_cyan, + NamedColor::White => colors.terminal_ansi_white, + NamedColor::BrightBlack => colors.terminal_ansi_bright_black, + NamedColor::BrightRed => colors.terminal_ansi_bright_red, + NamedColor::BrightGreen => colors.terminal_ansi_bright_green, + NamedColor::BrightYellow => colors.terminal_ansi_bright_yellow, + NamedColor::BrightBlue => colors.terminal_ansi_bright_blue, + NamedColor::BrightMagenta => colors.terminal_ansi_bright_magenta, + NamedColor::BrightCyan => colors.terminal_ansi_bright_cyan, + NamedColor::BrightWhite => colors.terminal_ansi_bright_white, + NamedColor::Foreground => colors.terminal_foreground, + NamedColor::Background => colors.terminal_ansi_background, + NamedColor::Cursor => theme.players().local().cursor, + NamedColor::DimBlack => colors.terminal_ansi_dim_black, + NamedColor::DimRed => colors.terminal_ansi_dim_red, + NamedColor::DimGreen => colors.terminal_ansi_dim_green, + NamedColor::DimYellow => colors.terminal_ansi_dim_yellow, + NamedColor::DimBlue => colors.terminal_ansi_dim_blue, + NamedColor::DimMagenta => colors.terminal_ansi_dim_magenta, + NamedColor::DimCyan => colors.terminal_ansi_dim_cyan, + NamedColor::DimWhite => colors.terminal_ansi_dim_white, + NamedColor::BrightForeground => colors.terminal_bright_foreground, + NamedColor::DimForeground => colors.terminal_dim_foreground, + }, + // 'True' colors + Color::Spec(rgb) => terminal::rgba_color(rgb.r, rgb.g, rgb.b), + // 8 bit, indexed colors + Color::Indexed(i) => terminal::get_color_at_index(*i as usize, theme), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AbsoluteLength, Hsla, font}; + use ui::utils::apca_contrast; + + #[test] + fn test_is_decorative_character() { + // Box Drawing characters (U+2500 to U+257F) + assert!(TerminalElement::is_decorative_character('─')); // U+2500 + assert!(TerminalElement::is_decorative_character('│')); // U+2502 + assert!(TerminalElement::is_decorative_character('┌')); // U+250C + assert!(TerminalElement::is_decorative_character('┐')); // U+2510 + assert!(TerminalElement::is_decorative_character('└')); // U+2514 + assert!(TerminalElement::is_decorative_character('┘')); // U+2518 + assert!(TerminalElement::is_decorative_character('┼')); // U+253C + + // Block Elements (U+2580 to U+259F) + assert!(TerminalElement::is_decorative_character('▀')); // U+2580 + assert!(TerminalElement::is_decorative_character('▄')); // U+2584 + assert!(TerminalElement::is_decorative_character('█')); // U+2588 + assert!(TerminalElement::is_decorative_character('░')); // U+2591 + assert!(TerminalElement::is_decorative_character('▒')); // U+2592 + assert!(TerminalElement::is_decorative_character('▓')); // U+2593 + + // Geometric Shapes - block/box-like subset (U+25A0 to U+25D7) + assert!(TerminalElement::is_decorative_character('■')); // U+25A0 + assert!(TerminalElement::is_decorative_character('□')); // U+25A1 + assert!(TerminalElement::is_decorative_character('▲')); // U+25B2 + assert!(TerminalElement::is_decorative_character('▼')); // U+25BC + assert!(TerminalElement::is_decorative_character('◆')); // U+25C6 + assert!(TerminalElement::is_decorative_character('●')); // U+25CF + + // The specific character from the issue + assert!(TerminalElement::is_decorative_character('◗')); // U+25D7 + assert!(TerminalElement::is_decorative_character('◘')); // U+25D8 (now included in Geometric Shapes) + assert!(TerminalElement::is_decorative_character('◙')); // U+25D9 (now included in Geometric Shapes) + + // Powerline symbols (Private Use Area) + assert!(TerminalElement::is_decorative_character('\u{E0B0}')); // Powerline right triangle + assert!(TerminalElement::is_decorative_character('\u{E0B2}')); // Powerline left triangle + assert!(TerminalElement::is_decorative_character('\u{E0B4}')); // Powerline right half circle (the actual issue!) + assert!(TerminalElement::is_decorative_character('\u{E0B6}')); // Powerline left half circle + assert!(TerminalElement::is_decorative_character('\u{E0CA}')); // Powerline mirrored ice waveform + assert!(TerminalElement::is_decorative_character('\u{E0D7}')); // Powerline left triangle inverted + + // Characters that should NOT be considered decorative + assert!(!TerminalElement::is_decorative_character('A')); // Regular letter + assert!(!TerminalElement::is_decorative_character('$')); // Symbol + assert!(!TerminalElement::is_decorative_character(' ')); // Space + assert!(!TerminalElement::is_decorative_character('←')); // U+2190 (Arrow, not in our ranges) + assert!(!TerminalElement::is_decorative_character('→')); // U+2192 (Arrow, not in our ranges) + assert!(!TerminalElement::is_decorative_character('\u{F00C}')); // Font Awesome check (icon, needs contrast) + assert!(!TerminalElement::is_decorative_character('\u{E711}')); // Devicons (icon, needs contrast) + assert!(!TerminalElement::is_decorative_character('\u{EA71}')); // Codicons folder (icon, needs contrast) + assert!(!TerminalElement::is_decorative_character('\u{F401}')); // Octicons (icon, needs contrast) + assert!(!TerminalElement::is_decorative_character('\u{1F600}')); // Emoji (not in our ranges) + } + + #[test] + fn test_decorative_character_boundary_cases() { + // Test exact boundaries of our ranges + // Box Drawing range boundaries + assert!(TerminalElement::is_decorative_character('\u{2500}')); // First char + assert!(TerminalElement::is_decorative_character('\u{257F}')); // Last char + assert!(!TerminalElement::is_decorative_character('\u{24FF}')); // Just before + + // Block Elements range boundaries + assert!(TerminalElement::is_decorative_character('\u{2580}')); // First char + assert!(TerminalElement::is_decorative_character('\u{259F}')); // Last char + + // Geometric Shapes subset boundaries + assert!(TerminalElement::is_decorative_character('\u{25A0}')); // First char + assert!(TerminalElement::is_decorative_character('\u{25FF}')); // Last char + assert!(!TerminalElement::is_decorative_character('\u{2600}')); // Just after + + // Sextant range boundaries + assert!(TerminalElement::is_decorative_character('\u{1FB00}')); // First char + assert!(TerminalElement::is_decorative_character('\u{1FB3B}')); // Last char + assert!(!TerminalElement::is_decorative_character('\u{1FAFF}')); // Just before + assert!(!TerminalElement::is_decorative_character('\u{1FB3C}')); // Just after + } + + #[test] + fn test_sextant_char_to_filled_bits() { + // U+1FB00 BLOCK SEXTANT-1: only the top-left subcell. + assert_eq!( + TerminalElement::sextant_char_to_filled_bits('\u{1FB00}'), + Some(0b00_0001) + ); + // U+1FB13 BLOCK SEXTANT-35 and U+1FB14 BLOCK SEXTANT-235 straddle the + // gap left by `▌` (0b01_0101). + assert_eq!( + TerminalElement::sextant_char_to_filled_bits('\u{1FB13}'), + Some(0b01_0100) + ); + assert_eq!( + TerminalElement::sextant_char_to_filled_bits('\u{1FB14}'), + Some(0b01_0110) + ); + // U+1FB3B BLOCK SEXTANT-12356: everything except the bottom-left subcell. + assert_eq!( + TerminalElement::sextant_char_to_filled_bits('\u{1FB3B}'), + Some(0b11_1110) + ); + assert_eq!(TerminalElement::sextant_char_to_filled_bits('█'), None); + assert_eq!( + TerminalElement::sextant_char_to_filled_bits('\u{1FB3C}'), + None + ); + } + + #[test] + fn test_block_element_rects_merge_across_adjacent_full_blocks() { + let color = Hsla::default(); + let mut regions = Vec::new(); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + '█', + color, + &mut regions, + )); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 1), + '█', + color, + &mut regions, + )); + + assert_eq!( + regions.len(), + 1, + "adjacent full blocks should be merged eagerly by push_block_element_region" + ); + + let rects = TerminalElement::block_element_regions_to_rects(regions); + + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].point.line(), 0); + assert_eq!(rects[0].point.column(), 0); + assert_eq!(rects[0].num_of_columns, 16); + assert_eq!(rects[0].num_of_lines, 24); + assert_eq!(rects[0].line(), 0); + } + + #[test] + fn test_block_element_rects_cover_half_blocks_and_sextants() { + let color = Hsla::default(); + let mut regions = Vec::new(); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + '▄', + color, + &mut regions, + )); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(1, 0), + '\u{1FB00}', + color, + &mut regions, + )); + + let rects = TerminalElement::block_element_regions_to_rects(regions); + + assert!(rects.iter().any(|rect| { + rect.point.line() == 12 + && rect.point.column() == 0 + && rect.num_of_columns == 8 + && rect.num_of_lines == 12 + })); + assert!(rects.iter().any(|rect| { + rect.point.line() == 24 + && rect.point.column() == 0 + && rect.num_of_columns == 4 + && rect.num_of_lines == 8 + })); + } + + #[test] + fn test_block_element_rects_cover_eighth_blocks() { + let color = Hsla::default(); + + for (ch, expected_column, expected_line, expected_columns, expected_lines) in [ + ('▁', 0, 21, 8, 3), + ('▇', 0, 3, 8, 21), + ('▉', 0, 0, 7, 24), + ('▏', 0, 0, 1, 24), + ('▔', 0, 0, 8, 3), + ('▕', 7, 0, 1, 24), + ] { + let mut regions = Vec::new(); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + ch, + color, + &mut regions, + )); + let rects = TerminalElement::block_element_regions_to_rects(regions); + + assert_eq!(rects.len(), 1, "unexpected rect count for {ch}"); + assert_eq!(rects[0].point.column(), expected_column, "column for {ch}"); + assert_eq!(rects[0].point.line(), expected_line, "line for {ch}"); + assert_eq!( + rects[0].num_of_columns, expected_columns, + "columns for {ch}" + ); + assert_eq!(rects[0].num_of_lines, expected_lines, "lines for {ch}"); + } + } + + #[test] + fn test_block_element_rects_cover_quadrants() { + let color = Hsla::default(); + let mut regions = Vec::new(); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + '▚', + color, + &mut regions, + )); + + let rects = TerminalElement::block_element_regions_to_rects(regions); + + assert_eq!(rects.len(), 2); + assert!(rects.iter().any(|rect| { + rect.point.line() == 0 + && rect.point.column() == 0 + && rect.num_of_columns == 4 + && rect.num_of_lines == 12 + })); + assert!(rects.iter().any(|rect| { + rect.point.line() == 12 + && rect.point.column() == 4 + && rect.num_of_columns == 4 + && rect.num_of_lines == 12 + })); + } + + #[test] + fn test_block_element_rects_cover_shades() { + let color = gpui::red(); + let mut regions = Vec::new(); + assert!(TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + '▒', + color, + &mut regions, + )); + + let rects = TerminalElement::block_element_regions_to_rects(regions); + + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].num_of_columns, 8); + assert_eq!(rects[0].num_of_lines, 24); + assert_eq!(rects[0].color, color.opacity(0.5)); + } + + #[test] + fn test_block_element_chars_fully_handled_within_cell() { + let color = Hsla::default(); + + for codepoint in (0x2580..=0x259F).chain(0x1FB00..=0x1FB3B) { + let ch = char::from_u32(codepoint).expect("valid block element codepoint"); + let mut regions = Vec::new(); + assert!( + TerminalElement::collect_block_element_regions( + LayoutPoint::new(0, 0), + ch, + color, + &mut regions, + ), + "U+{codepoint:04X} {ch} should be custom-painted" + ); + assert!( + !regions.is_empty(), + "U+{codepoint:04X} {ch} should fill at least one subcell" + ); + + let mut filled = + [[false; BLOCK_SUBCELL_COLUMNS as usize]; BLOCK_SUBCELL_LINES as usize]; + for region in ®ions { + assert!( + region.start_line <= region.end_line + && region.start_col <= region.end_col + && (0..BLOCK_SUBCELL_LINES).contains(®ion.start_line) + && (0..BLOCK_SUBCELL_LINES).contains(®ion.end_line) + && (0..BLOCK_SUBCELL_COLUMNS).contains(®ion.start_col) + && (0..BLOCK_SUBCELL_COLUMNS).contains(®ion.end_col), + "U+{codepoint:04X} {ch} paints outside its cell: {region:?}" + ); + for line in region.start_line..=region.end_line { + for column in region.start_col..=region.end_col { + assert!( + !filled[line as usize][column as usize], + "U+{codepoint:04X} {ch} paints subcell ({line}, {column}) twice" + ); + filled[line as usize][column as usize] = true; + } + } + } + } + } + + #[test] + fn test_decorative_characters_bypass_contrast_adjustment() { + // Decorative characters should not be affected by contrast adjustment + + // The specific character from issue #34234 + let problematic_char = '◗'; // U+25D7 + assert!( + TerminalElement::is_decorative_character(problematic_char), + "Character ◗ (U+25D7) should be recognized as decorative" + ); + + // Verify some other commonly used decorative characters + assert!(TerminalElement::is_decorative_character('│')); // Vertical line + assert!(TerminalElement::is_decorative_character('─')); // Horizontal line + assert!(TerminalElement::is_decorative_character('█')); // Full block + assert!(TerminalElement::is_decorative_character('▓')); // Dark shade + assert!(TerminalElement::is_decorative_character('■')); // Black square + assert!(TerminalElement::is_decorative_character('●')); // Black circle + + // Verify normal text characters are NOT decorative + assert!(!TerminalElement::is_decorative_character('A')); + assert!(!TerminalElement::is_decorative_character('1')); + assert!(!TerminalElement::is_decorative_character('$')); + assert!(!TerminalElement::is_decorative_character(' ')); + } + + #[test] + fn test_is_app_chosen_exact_color() { + use terminal::{Color, NamedColor, Rgb}; + + // Indices 0..=15 are theme-overridable ANSI colors; contrast adjustment must still apply. + assert!(!TerminalElement::is_app_chosen_exact_color( + &Color::Indexed(0) + )); + assert!(!TerminalElement::is_app_chosen_exact_color( + &Color::Indexed(15) + )); + + // Boundary: index 16 is the first entry of the 6x6x6 cube — application-chosen. + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed( + 16 + ))); + // Interior of the cube. + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed( + 17 + ))); + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed( + 231 + ))); + // Grayscale ramp boundaries. + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed( + 232 + ))); + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed( + 255 + ))); + + // 24-bit true color is always application-chosen. + assert!(TerminalElement::is_app_chosen_exact_color(&Color::Spec( + Rgb { + r: 10, + g: 20, + b: 30 + } + ))); + + // Named colors are theme-defined and must go through contrast adjustment. + assert!(!TerminalElement::is_app_chosen_exact_color(&Color::Named( + NamedColor::Red + ))); + assert!(!TerminalElement::is_app_chosen_exact_color(&Color::Named( + NamedColor::Foreground + ))); + } + + #[test] + fn test_contrast_adjustment_logic() { + // Test the core contrast adjustment logic without needing full app context + + // Test case 1: Light colors (poor contrast) + let white_fg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 1.0, + a: 1.0, + }; + let light_gray_bg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.95, + a: 1.0, + }; + + // Should have poor contrast + let actual_contrast = apca_contrast(white_fg, light_gray_bg).abs(); + assert!( + actual_contrast < 30.0, + "White on light gray should have poor APCA contrast: {}", + actual_contrast + ); + + // After adjustment with minimum APCA contrast of 45, should be darker + let adjusted = ensure_minimum_contrast(white_fg, light_gray_bg, 45.0); + assert!( + adjusted.l < white_fg.l, + "Adjusted color should be darker than original" + ); + let adjusted_contrast = apca_contrast(adjusted, light_gray_bg).abs(); + assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast"); + + // Test case 2: Dark colors (poor contrast) + let black_fg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.0, + a: 1.0, + }; + let dark_gray_bg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.05, + a: 1.0, + }; + + // Should have poor contrast + let actual_contrast = apca_contrast(black_fg, dark_gray_bg).abs(); + assert!( + actual_contrast < 30.0, + "Black on dark gray should have poor APCA contrast: {}", + actual_contrast + ); + + // After adjustment with minimum APCA contrast of 45, should be lighter + let adjusted = ensure_minimum_contrast(black_fg, dark_gray_bg, 45.0); + assert!( + adjusted.l > black_fg.l, + "Adjusted color should be lighter than original" + ); + let adjusted_contrast = apca_contrast(adjusted, dark_gray_bg).abs(); + assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast"); + + // Test case 3: Already good contrast + let good_contrast = ensure_minimum_contrast(black_fg, white_fg, 45.0); + assert_eq!( + good_contrast, black_fg, + "Good contrast should not be adjusted" + ); + } + + #[test] + fn test_true_color_red_blue_not_washed_out_on_dark_bg() { + // Red and blue have inherently low perceptual luminance in APCA. + // Pure #ff0000 only achieves Lc ~35 against #1e1e1e — below the + // default Lc 45 threshold. ensure_minimum_contrast would lighten + // them, washing out the color. This is why cell_style skips the + // adjustment for Color::Spec (24-bit true color). + let dark_bg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.05, + a: 1.0, + }; + + for (name, r, g, b) in [ + ("red", 225, 80, 80), + ("blue", 80, 80, 225), + ("pure red", 255, 0, 0), + ] { + let color = terminal::rgba_color(r, g, b); + let contrast = apca_contrast(color, dark_bg).abs(); + assert!( + contrast < 45.0, + "{name} should have APCA < 45 on dark bg, got {contrast}", + ); + + let adjusted = ensure_minimum_contrast(color, dark_bg, 45.0); + assert!( + adjusted.l > color.l, + "{name} would be lightened by contrast adjustment (l: {} -> {})", + color.l, + adjusted.l, + ); + } + } + + #[test] + fn test_white_on_white_contrast_issue() { + // This test reproduces the exact issue from the bug report + // where white ANSI text on white background should be adjusted + + // Simulate One Light theme colors + let white_fg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.98, // #fafafaff is approximately 98% lightness + a: 1.0, + }; + let white_bg = gpui::Hsla { + h: 0.0, + s: 0.0, + l: 0.98, // Same as foreground - this is the problem! + a: 1.0, + }; + + // With minimum contrast of 0.0, no adjustment should happen + let no_adjust = ensure_minimum_contrast(white_fg, white_bg, 0.0); + assert_eq!(no_adjust, white_fg, "No adjustment with min_contrast 0.0"); + + // With minimum APCA contrast of 15, it should adjust to a darker color + let adjusted = ensure_minimum_contrast(white_fg, white_bg, 15.0); + assert!( + adjusted.l < white_fg.l, + "White on white should become darker, got l={}", + adjusted.l + ); + + // Verify the contrast is now acceptable + let new_contrast = apca_contrast(adjusted, white_bg).abs(); + assert!( + new_contrast >= 15.0, + "Adjusted APCA contrast {} should be >= 15.0", + new_contrast + ); + } + + #[test] + fn test_batched_text_run_can_append() { + let style1 = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::red(), + ..Default::default() + }; + + let style2 = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::red(), + ..Default::default() + }; + + let style3 = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::blue(), // Different color + ..Default::default() + }; + + let font_size = AbsoluteLength::Pixels(px(12.0)); + let batch = BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'a', style1, font_size); + + // Should be able to append same style + assert!(batch.can_append(&style2)); + + // Should not be able to append different style + assert!(!batch.can_append(&style3)); + } + + #[test] + fn test_batched_text_run_append() { + let style = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::red(), + ..Default::default() + }; + + let font_size = AbsoluteLength::Pixels(px(12.0)); + let mut batch = + BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'a', style, font_size); + + assert_eq!(batch.text, "a"); + assert_eq!(batch.cell_count, 1); + assert_eq!(batch.style.len, 1); + + batch.append_char('b'); + + assert_eq!(batch.text, "ab"); + assert_eq!(batch.cell_count, 2); + assert_eq!(batch.style.len, 2); + + batch.append_char('c'); + + assert_eq!(batch.text, "abc"); + assert_eq!(batch.cell_count, 3); + assert_eq!(batch.style.len, 3); + } + + #[test] + fn test_batched_text_run_append_char() { + let style = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::red(), + ..Default::default() + }; + + let font_size = AbsoluteLength::Pixels(px(12.0)); + let mut batch = + BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'x', style, font_size); + + assert_eq!(batch.text, "x"); + assert_eq!(batch.cell_count, 1); + assert_eq!(batch.style.len, 1); + + batch.append_char('y'); + + assert_eq!(batch.text, "xy"); + assert_eq!(batch.cell_count, 2); + assert_eq!(batch.style.len, 2); + + // Test with multi-byte character + batch.append_char('😀'); + + assert_eq!(batch.text, "xy😀"); + assert_eq!(batch.cell_count, 3); + assert_eq!(batch.style.len, 6); // 1 + 1 + 4 bytes for emoji + } + + #[test] + fn test_batched_text_run_append_zero_width_char() { + let style = TextRun { + len: 1, + font: font("Helvetica"), + color: Hsla::red(), + ..Default::default() + }; + + let font_size = AbsoluteLength::Pixels(px(12.0)); + let mut batch = + BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'x', style, font_size); + + let combining = '\u{0301}'; + batch.append_zero_width_chars(&[combining]); + + assert_eq!(batch.text, format!("x{}", combining)); + assert_eq!(batch.cell_count, 1); + assert_eq!(batch.style.len, 1 + combining.len_utf8()); + } + + #[test] + fn test_background_region_can_merge() { + let color1 = Hsla::red(); + let color2 = Hsla::blue(); + + // Test horizontal merging + let mut region1 = BackgroundRegion::new(0, 0, color1); + region1.end_col = 5; + let region2 = BackgroundRegion::new(0, 6, color1); + assert!(region1.can_merge_with(®ion2)); + + // Test vertical merging with same column span + let mut region3 = BackgroundRegion::new(0, 0, color1); + region3.end_col = 5; + let mut region4 = BackgroundRegion::new(1, 0, color1); + region4.end_col = 5; + assert!(region3.can_merge_with(®ion4)); + + // Test cannot merge different colors + let region5 = BackgroundRegion::new(0, 0, color1); + let region6 = BackgroundRegion::new(0, 1, color2); + assert!(!region5.can_merge_with(®ion6)); + + // Test cannot merge non-adjacent regions + let region7 = BackgroundRegion::new(0, 0, color1); + let region8 = BackgroundRegion::new(0, 2, color1); + assert!(!region7.can_merge_with(®ion8)); + + // Test cannot merge vertical regions with different column spans + let mut region9 = BackgroundRegion::new(0, 0, color1); + region9.end_col = 5; + let mut region10 = BackgroundRegion::new(1, 0, color1); + region10.end_col = 6; + assert!(!region9.can_merge_with(®ion10)); + } + + #[test] + fn test_background_region_merge() { + let color = Hsla::red(); + + // Test horizontal merge + let mut region1 = BackgroundRegion::new(0, 0, color); + region1.end_col = 5; + let mut region2 = BackgroundRegion::new(0, 6, color); + region2.end_col = 10; + region1.merge_with(®ion2); + assert_eq!(region1.start_col, 0); + assert_eq!(region1.end_col, 10); + assert_eq!(region1.start_line, 0); + assert_eq!(region1.end_line, 0); + + // Test vertical merge + let mut region3 = BackgroundRegion::new(0, 0, color); + region3.end_col = 5; + let mut region4 = BackgroundRegion::new(1, 0, color); + region4.end_col = 5; + region3.merge_with(®ion4); + assert_eq!(region3.start_col, 0); + assert_eq!(region3.end_col, 5); + assert_eq!(region3.start_line, 0); + assert_eq!(region3.end_line, 1); + } + + #[test] + fn test_merge_background_regions() { + let color = Hsla::red(); + + // Test merging multiple adjacent regions + let regions = vec![ + BackgroundRegion::new(0, 0, color), + BackgroundRegion::new(0, 1, color), + BackgroundRegion::new(0, 2, color), + BackgroundRegion::new(1, 0, color), + BackgroundRegion::new(1, 1, color), + BackgroundRegion::new(1, 2, color), + ]; + + let merged = merge_background_regions(regions); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].start_line, 0); + assert_eq!(merged[0].end_line, 1); + assert_eq!(merged[0].start_col, 0); + assert_eq!(merged[0].end_col, 2); + + // Test with non-mergeable regions + let color2 = Hsla::blue(); + let regions2 = vec![ + BackgroundRegion::new(0, 0, color), + BackgroundRegion::new(0, 2, color), // Gap at column 1 + BackgroundRegion::new(1, 0, color2), // Different color + ]; + + let merged2 = merge_background_regions(regions2); + assert_eq!(merged2.len(), 3); + } + + #[test] + fn test_screen_position_filtering_with_positive_lines() { + // Test the unified screen-position-based filtering approach. + // This works for both Scrollable and Inline modes because we filter + // by enumerated line group index, not by cell.point.line values. + use itertools::Itertools; + use terminal::{Cell, IndexedCell, Point}; + + // Create mock cells for lines 0-23 (typical terminal with 24 visible lines) + let mut cells = Vec::new(); + for line in 0..24i32 { + for col in 0..3i32 { + cells.push(IndexedCell { + point: Point::new(line, col as usize), + cell: Cell::default(), + }); + } + } + + // Scenario: Terminal partially scrolled above viewport + // First 5 lines (0-4) are clipped, lines 5-15 should be visible + let rows_above_viewport = 5usize; + let visible_row_count = 11usize; + + // Apply the same filtering logic as in the render code + let filtered: Vec<_> = cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_above_viewport) + .take(visible_row_count) + .flat_map(|(_, line_cells)| line_cells) + .collect(); + + // Should have lines 5-15 (11 lines * 3 cells each = 33 cells) + assert_eq!(filtered.len(), 11 * 3, "Should have 33 cells for 11 lines"); + + // First filtered cell should be line 5 + assert_eq!( + filtered.first().unwrap().point.line, + 5, + "First cell should be on line 5" + ); + + // Last filtered cell should be line 15 + assert_eq!( + filtered.last().unwrap().point.line, + 15, + "Last cell should be on line 15" + ); + } + + #[test] + fn test_screen_position_filtering_with_negative_lines() { + // This is the key test! In Scrollable mode, cells have NEGATIVE line numbers + // for scrollback history. The screen-position filtering approach works because + // we filter by enumerated line group index, not by cell.point.line values. + use itertools::Itertools; + use terminal::{Cell, IndexedCell, Point}; + + // Simulate cells from a scrolled terminal with scrollback + // These have negative line numbers representing scrollback history + let mut scrollback_cells = Vec::new(); + for line in -588i32..=-578i32 { + for col in 0..80i32 { + scrollback_cells.push(IndexedCell { + point: Point::new(line, col as usize), + cell: Cell::default(), + }); + } + } + + // Scenario: First 3 screen rows clipped, show next 5 rows + let rows_above_viewport = 3usize; + let visible_row_count = 5usize; + + // Apply the same filtering logic as in the render code + let filtered: Vec<_> = scrollback_cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_above_viewport) + .take(visible_row_count) + .flat_map(|(_, line_cells)| line_cells) + .collect(); + + // Should have 5 lines * 80 cells = 400 cells + assert_eq!(filtered.len(), 5 * 80, "Should have 400 cells for 5 lines"); + + // First filtered cell should be line -585 (skipped 3 lines from -588) + assert_eq!( + filtered.first().unwrap().point.line, + -585, + "First cell should be on line -585" + ); + + // Last filtered cell should be line -581 (5 lines: -585, -584, -583, -582, -581) + assert_eq!( + filtered.last().unwrap().point.line, + -581, + "Last cell should be on line -581" + ); + } + + #[test] + fn test_screen_position_filtering_skip_all() { + // Test what happens when we skip more rows than exist + use itertools::Itertools; + use terminal::{Cell, IndexedCell, Point}; + + let mut cells = Vec::new(); + for line in 0..10i32 { + cells.push(IndexedCell { + point: Point::new(line, 0), + cell: Cell::default(), + }); + } + + // Skip more rows than exist + let rows_above_viewport = 100usize; + let visible_row_count = 5usize; + + let filtered: Vec<_> = cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_above_viewport) + .take(visible_row_count) + .flat_map(|(_, line_cells)| line_cells) + .collect(); + + assert_eq!( + filtered.len(), + 0, + "Should have no cells when all are skipped" + ); + } + + #[test] + fn test_layout_grid_positioning_math() { + // Test the math that layout_grid uses for positioning. + // When we skip N rows, we pass N as start_line_offset to layout_grid, + // which positions the first visible line at screen row N. + + // Scenario: Terminal at y=-100px, line_height=20px + // First 5 screen rows are above viewport (clipped) + // So we skip 5 rows and pass offset=5 to layout_grid + + let terminal_origin_y = -100.0f32; + let line_height = 20.0f32; + let rows_skipped = 5; + + // The first visible line (at offset 5) renders at: + // y = terminal_origin + offset * line_height = -100 + 5*20 = 0 + let first_visible_y = terminal_origin_y + rows_skipped as f32 * line_height; + assert_eq!( + first_visible_y, 0.0, + "First visible line should be at viewport top (y=0)" + ); + + // The 6th visible line (at offset 10) renders at: + let sixth_visible_y = terminal_origin_y + (rows_skipped + 5) as f32 * line_height; + assert_eq!( + sixth_visible_y, 100.0, + "6th visible line should be at y=100" + ); + } + + #[test] + fn test_unified_filtering_works_for_both_modes() { + // This test proves that the unified screen-position filtering approach + // works for BOTH positive line numbers (Inline mode) and negative line + // numbers (Scrollable mode with scrollback). + // + // The key insight: we filter by enumerated line group index (screen position), + // not by cell.point.line values. This makes the filtering agnostic to the + // actual line numbers in the cells. + use itertools::Itertools; + use terminal::Point; + use terminal::{Cell, IndexedCell}; + + // Test with positive line numbers (Inline mode style) + let positive_cells: Vec<_> = (0..10i32) + .flat_map(|line| { + (0..3i32).map(move |col| IndexedCell { + point: Point::new(line, col as usize), + cell: Cell::default(), + }) + }) + .collect(); + + // Test with negative line numbers (Scrollable mode with scrollback) + let negative_cells: Vec<_> = (-10i32..0i32) + .flat_map(|line| { + (0..3i32).map(move |col| IndexedCell { + point: Point::new(line, col as usize), + cell: Cell::default(), + }) + }) + .collect(); + + let rows_to_skip = 3usize; + let rows_to_take = 4usize; + + // Filter positive cells + let positive_filtered: Vec<_> = positive_cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_to_skip) + .take(rows_to_take) + .flat_map(|(_, cells)| cells) + .collect(); + + // Filter negative cells + let negative_filtered: Vec<_> = negative_cells + .iter() + .chunk_by(|c| c.point.line) + .into_iter() + .skip(rows_to_skip) + .take(rows_to_take) + .flat_map(|(_, cells)| cells) + .collect(); + + // Both should have same count: 4 lines * 3 cells = 12 + assert_eq!(positive_filtered.len(), 12); + assert_eq!(negative_filtered.len(), 12); + + // Positive: lines 3, 4, 5, 6 + assert_eq!(positive_filtered.first().unwrap().point.line, 3); + assert_eq!(positive_filtered.last().unwrap().point.line, 6); + + // Negative: lines -7, -6, -5, -4 + assert_eq!(negative_filtered.first().unwrap().point.line, -7); + assert_eq!(negative_filtered.last().unwrap().point.line, -4); + } +} diff --git a/vendor/zed-terminal-view/src/terminal_panel.rs b/vendor/zed-terminal-view/src/terminal_panel.rs new file mode 100644 index 00000000..f2456199 --- /dev/null +++ b/vendor/zed-terminal-view/src/terminal_panel.rs @@ -0,0 +1,2581 @@ +use std::{cmp, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration}; + +use crate::{ + TerminalView, default_working_directory, + persistence::{ + SerializedItems, SerializedTerminalPanel, deserialize_terminal_panel, serialize_pane_group, + }, +}; +use breadcrumbs::Breadcrumbs; +use collections::HashMap; +use db::kvp::KeyValueStore; +use futures::{channel::oneshot, future::join_all}; +use gpui::{ + Action, Anchor, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt, WeakEntity, + Window, actions, +}; +use itertools::Itertools; +use project::{Fs, Project}; + +use settings::{Settings, TerminalDockPosition}; +use task::{RevealStrategy, RevealTarget, Shell, ShellBuilder, SpawnInTerminal, TaskId}; +use terminal::{Terminal, terminal_settings::TerminalSettings}; +use ui::{ + ButtonLike, Clickable, ContextMenu, FluentBuilder, PopoverMenu, SplitButton, Toggleable, + Tooltip, prelude::*, +}; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, + ActivatePaneUp, ActivatePreviousPane, DraggedTab, ItemId, MoveItemToPane, + MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, Pane, + PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, SwapPaneDown, + SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, + dock::{DockPosition, Panel, PanelEvent, PanelHandle}, + item::SerializableItem, + move_active_item, pane, +}; + +use anyhow::{Result, anyhow}; +use zed_actions::assistant::InlineAssist; + +const TERMINAL_PANEL_KEY: &str = "TerminalPanel"; + +actions!( + terminal_panel, + [ + /// Toggles the terminal panel. + Toggle, + /// Toggles focus on the terminal panel. + ToggleFocus + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new( + |workspace: &mut Workspace, _window, _: &mut Context| { + workspace.register_action(TerminalPanel::new_terminal); + workspace.register_action(TerminalPanel::open_terminal); + workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { + if is_enabled_in_workspace(workspace, cx) { + workspace.toggle_panel_focus::(window, cx); + } + }); + workspace.register_action(|workspace, _: &Toggle, window, cx| { + if is_enabled_in_workspace(workspace, cx) { + if !workspace.toggle_panel_focus::(window, cx) { + workspace.close_panel::(window, cx); + } + } + }); + }, + ) + .detach(); +} + +pub struct TerminalPanel { + pub(crate) active_pane: Entity, + pub(crate) center: PaneGroup, + focus_handle: FocusHandle, + fs: Arc, + workspace: WeakEntity, + pending_serialization: Task>, + pending_terminals_to_add: usize, + deferred_tasks: HashMap>, + assistant_enabled: bool, + active: bool, +} + +impl TerminalPanel { + pub fn new(workspace: &Workspace, window: &mut Window, cx: &mut Context) -> Self { + let project = workspace.project(); + let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), false, window, cx); + let center = PaneGroup::new(pane.clone()); + let terminal_panel = Self { + center, + active_pane: pane, + focus_handle: cx.focus_handle(), + fs: workspace.app_state().fs.clone(), + workspace: workspace.weak_handle(), + pending_serialization: Task::ready(None), + pending_terminals_to_add: 0, + deferred_tasks: HashMap::default(), + assistant_enabled: false, + active: false, + }; + terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx); + terminal_panel + } + + pub fn set_assistant_enabled(&mut self, enabled: bool, cx: &mut Context) { + self.assistant_enabled = enabled; + for pane in self.center.panes() { + self.apply_tab_bar_buttons(pane, cx); + } + } + + pub(crate) fn apply_tab_bar_buttons( + &self, + terminal_pane: &Entity, + cx: &mut Context, + ) { + let assistant_enabled = self.assistant_enabled; + terminal_pane.update(cx, |pane, cx| { + pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| { + let split_context = pane + .active_item() + .and_then(|item| item.downcast::()) + .map(|terminal_view| terminal_view.read(cx).focus_handle.clone()); + let has_focused_rename_editor = pane + .active_item() + .and_then(|item| item.downcast::()) + .is_some_and(|view| view.read(cx).rename_editor_is_focused(window, cx)); + if !pane.has_focus(window, cx) + && !pane.context_menu_focused(window, cx) + && !has_focused_rename_editor + { + return (None, None); + } + let focus_handle = pane.focus_handle(cx); + let right_children = h_flex() + .gap(DynamicSpacing::Base02.rems(cx)) + .child( + PopoverMenu::new("terminal-tab-bar-popover-menu") + .trigger_with_tooltip( + IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small), + Tooltip::text("New…"), + ) + .anchor(Anchor::TopRight) + .with_handle(pane.new_item_context_menu_handle.clone()) + .menu(move |window, cx| { + let focus_handle = focus_handle.clone(); + let menu = ContextMenu::build(window, cx, |menu, _, _| { + menu.context(focus_handle.clone()) + .action( + "New Terminal", + workspace::NewTerminal::default().boxed_clone(), + ) + // We want the focus to go back to terminal panel once task modal is dismissed, + // hence we focus that first. Otherwise, we'd end up without a focused element, as + // context menu will be gone the moment we spawn the modal. + .action( + "Spawn Task", + zed_actions::Spawn::modal().boxed_clone(), + ) + }); + + Some(menu) + }), + ) + .when(assistant_enabled, |this| { + this.when_some(split_context.clone(), |this, focus_handle| { + this.child(InlineAssistTabBarButton { focus_handle }) + }) + }) + .child( + PopoverMenu::new("terminal-pane-tab-bar-split") + .trigger_with_tooltip( + IconButton::new("terminal-pane-split", IconName::Split) + .icon_size(IconSize::Small), + Tooltip::text("Split Pane"), + ) + .anchor(Anchor::TopRight) + .with_handle(pane.split_item_context_menu_handle.clone()) + .menu({ + move |window, cx| { + ContextMenu::build(window, cx, |menu, _, _| { + menu.when_some( + split_context.clone(), + |menu, split_context| menu.context(split_context), + ) + .action("Split Right", SplitRight::default().boxed_clone()) + .action("Split Left", SplitLeft::default().boxed_clone()) + .action("Split Up", SplitUp::default().boxed_clone()) + .action("Split Down", SplitDown::default().boxed_clone()) + }) + .into() + } + }), + ) + .child({ + let zoomed = pane.is_zoomed(); + IconButton::new("toggle_zoom", IconName::Maximize) + .icon_size(IconSize::Small) + .toggle_state(zoomed) + .selected_icon(IconName::Minimize) + .on_click(cx.listener(|pane, _, window, cx| { + pane.toggle_zoom(&workspace::ToggleZoom, window, cx); + })) + .tooltip(move |_window, cx| { + Tooltip::for_action( + if zoomed { "Zoom Out" } else { "Zoom In" }, + &ToggleZoom, + cx, + ) + }) + }) + .into_any_element() + .into(); + (None, right_children) + }); + }); + } + + fn serialization_key(workspace: &Workspace) -> Option { + workspace + .database_id() + .map(|id| i64::from(id).to_string()) + .or(workspace.session_id()) + .map(|id| format!("{:?}-{:?}", TERMINAL_PANEL_KEY, id)) + } + + pub async fn load( + workspace: WeakEntity, + mut cx: AsyncWindowContext, + ) -> Result> { + let mut terminal_panel = None; + + if let Some((database_id, serialization_key, kvp)) = workspace + .read_with(&cx, |workspace, cx| { + workspace + .database_id() + .zip(TerminalPanel::serialization_key(workspace)) + .map(|(id, key)| (id, key, KeyValueStore::global(cx))) + }) + .ok() + .flatten() + && let Some(serialized_panel) = cx + .background_spawn(async move { kvp.read_kvp(&serialization_key) }) + .await + .log_err() + .flatten() + .map(|panel| serde_json::from_str::(&panel)) + .transpose() + .log_err() + .flatten() + && let Ok(serialized) = workspace + .update_in(&mut cx, |workspace, window, cx| { + deserialize_terminal_panel( + workspace.weak_handle(), + workspace.project().clone(), + database_id, + serialized_panel, + window, + cx, + ) + })? + .await + { + terminal_panel = Some(serialized); + } + + let terminal_panel = if let Some(panel) = terminal_panel { + panel + } else { + workspace.update_in(&mut cx, |workspace, window, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + })? + }; + + if let Some(workspace) = workspace.upgrade() { + workspace.update(&mut cx, |workspace, _| { + workspace.set_terminal_provider(TerminalProvider(terminal_panel.clone())) + }); + } + + // Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace. + if let Some(workspace) = workspace.upgrade() { + let cleanup_task = workspace.update_in(&mut cx, |workspace, window, cx| { + let alive_item_ids = terminal_panel + .read(cx) + .center + .panes() + .into_iter() + .flat_map(|pane| pane.read(cx).items()) + .map(|item| item.item_id().as_u64() as ItemId) + .collect(); + workspace.database_id().map(|workspace_id| { + TerminalView::cleanup(workspace_id, alive_item_ids, window, cx) + }) + })?; + if let Some(task) = cleanup_task { + task.await.log_err(); + } + } + + if let Some(workspace) = workspace.upgrade() { + let should_focus = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.active_item(cx).is_none() + && workspace + .is_dock_at_position_open(terminal_panel.position(window, cx), cx) + }) + .unwrap_or(false); + + if should_focus { + terminal_panel + .update_in(&mut cx, |panel, window, cx| { + panel.active_pane.update(cx, |pane, cx| { + pane.focus_active_item(window, cx); + }); + }) + .ok(); + } + } + Ok(terminal_panel) + } + + fn handle_pane_event( + &mut self, + pane: &Entity, + event: &pane::Event, + window: &mut Window, + cx: &mut Context, + ) { + match event { + pane::Event::ActivateItem { .. } => self.serialize(cx), + pane::Event::RemovedItem { .. } => self.serialize(cx), + pane::Event::Remove { focus_on_pane } => { + let pane_count_before_removal = self.center.panes().len(); + let _removal_result = self.center.remove(pane, cx); + if pane_count_before_removal == 1 { + self.center.first_pane().update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }); + cx.emit(PanelEvent::Close); + } else if let Some(focus_on_pane) = + focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) + { + focus_on_pane.focus_handle(cx).focus(window, cx); + } + } + pane::Event::ZoomIn => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(true, cx); + }) + } + cx.emit(PanelEvent::ZoomIn); + cx.notify(); + } + pane::Event::ZoomOut => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }) + } + cx.emit(PanelEvent::ZoomOut); + cx.notify(); + } + pane::Event::AddItem { item } => { + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + item.added_to_pane(workspace, pane.clone(), window, cx) + }) + } + self.serialize(cx); + } + &pane::Event::Split { direction, mode } => { + match mode { + SplitMode::ClonePane | SplitMode::EmptyPane => { + let clone = matches!(mode, SplitMode::ClonePane); + let new_pane = self.new_pane_with_active_terminal(clone, window, cx); + let pane = pane.clone(); + cx.spawn_in(window, async move |panel, cx| { + let Some(new_pane) = new_pane.await else { + return; + }; + panel + .update_in(cx, |panel, window, cx| { + panel.center.split(&pane, &new_pane, direction, cx); + window.focus(&new_pane.focus_handle(cx), cx); + }) + .ok(); + }) + .detach(); + } + SplitMode::MovePane => { + let Some(item) = + pane.update(cx, |pane, cx| pane.take_active_item(window, cx)) + else { + return; + }; + let Ok(project) = self + .workspace + .update(cx, |workspace, _| workspace.project().clone()) + else { + return; + }; + let new_pane = + new_terminal_pane(self.workspace.clone(), project, false, window, cx); + new_pane.update(cx, |pane, cx| { + pane.add_item(item, true, true, None, window, cx); + }); + self.center.split(&pane, &new_pane, direction, cx); + window.focus(&new_pane.focus_handle(cx), cx); + } + }; + } + pane::Event::Focus => { + self.active_pane = pane.clone(); + } + pane::Event::ItemPinned | pane::Event::ItemUnpinned => { + self.serialize(cx); + } + + _ => {} + } + } + + fn new_pane_with_active_terminal( + &mut self, + clone: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + let workspace = workspace.read(cx); + let database_id = workspace.database_id(); + let weak_workspace = self.workspace.clone(); + let project = workspace.project().clone(); + let active_pane = &self.active_pane; + let terminal_view = if clone { + active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + } else { + None + }; + let working_directory = if clone { + terminal_view + .as_ref() + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace, cx)) + } else { + default_working_directory(workspace, cx) + }; + + let is_zoomed = if clone { + active_pane.read(cx).is_zoomed() + } else { + false + }; + cx.spawn_in(window, async move |panel, cx| { + let terminal = project + .update(cx, |project, cx| match terminal_view { + Some(view) => project.clone_terminal( + &view.read(cx).terminal.clone(), + cx, + working_directory, + ), + None => project.create_terminal_shell(working_directory, cx), + }) + .await + .log_err()?; + + panel + .update_in(cx, move |terminal_panel, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + weak_workspace.clone(), + database_id, + project.downgrade(), + window, + cx, + ) + })); + let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx); + terminal_panel.apply_tab_bar_buttons(&pane, cx); + pane.update(cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, window, cx); + }); + Some(pane) + }) + .ok() + .flatten() + }) + } + + pub fn open_terminal( + workspace: &mut Workspace, + action: &workspace::OpenTerminal, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal_panel) = workspace.panel::(cx) else { + return; + }; + + terminal_panel + .update(cx, |panel, cx| { + if action.local { + panel.add_local_terminal_shell(RevealStrategy::Always, window, cx) + } else { + panel.add_terminal_shell( + Some(action.working_directory.clone()), + RevealStrategy::Always, + window, + cx, + ) + } + }) + .detach_and_log_err(cx); + } + + pub fn spawn_task( + &mut self, + task: &SpawnInTerminal, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Err(anyhow!("failed to read workspace"))); + }; + + let project = workspace.read(cx).project().read(cx); + + if project.is_via_collab() { + return Task::ready(Err(anyhow!("cannot spawn tasks as a guest"))); + } + + let remote_client = project.remote_client(); + let is_windows = project.path_style(cx).is_windows(); + let remote_shell = remote_client + .as_ref() + .and_then(|remote_client| remote_client.read(cx).shell()); + + let shell = if let Some(remote_shell) = remote_shell + && task.shell == Shell::System + { + Shell::Program(remote_shell) + } else { + task.shell.clone() + }; + + let task = prepare_task_for_spawn(task, &shell, is_windows); + + if task.allow_concurrent_runs && task.use_new_terminal { + return self.spawn_in_new_terminal(task, window, cx); + } + + let mut terminals_for_task = self.terminals_for_task(&task.full_label, cx); + let Some(existing) = terminals_for_task.pop() else { + return self.spawn_in_new_terminal(task, window, cx); + }; + + let (existing_item_index, task_pane, existing_terminal) = existing; + if task.allow_concurrent_runs { + return self.replace_terminal( + task, + task_pane, + existing_item_index, + existing_terminal, + window, + cx, + ); + } + + let (tx, rx) = oneshot::channel(); + + self.deferred_tasks.insert( + task.id.clone(), + cx.spawn_in(window, async move |terminal_panel, cx| { + wait_for_terminals_tasks(terminals_for_task, cx).await; + let task = terminal_panel.update_in(cx, |terminal_panel, window, cx| { + if task.use_new_terminal { + terminal_panel.spawn_in_new_terminal(task, window, cx) + } else { + terminal_panel.replace_terminal( + task, + task_pane, + existing_item_index, + existing_terminal, + window, + cx, + ) + } + }); + if let Ok(task) = task { + tx.send(task.await).ok(); + } + }), + ); + + cx.spawn(async move |_, _| rx.await?) + } + + fn spawn_in_new_terminal( + &mut self, + spawn_task: SpawnInTerminal, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let reveal = spawn_task.reveal; + let reveal_target = spawn_task.reveal_target; + match reveal_target { + RevealTarget::Center => self + .workspace + .update(cx, |workspace, cx| { + Self::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_task(spawn_task, cx) + }) + }) + .unwrap_or_else(|e| Task::ready(Err(e))), + RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx), + } + } + + /// Create a new Terminal in the current working directory or the user's home directory + fn new_terminal( + workspace: &mut Workspace, + action: &workspace::NewTerminal, + window: &mut Window, + cx: &mut Context, + ) { + let center_pane = workspace.active_pane(); + let center_pane_has_focus = center_pane.focus_handle(cx).contains_focused(window, cx); + let active_center_item_is_terminal = center_pane + .read(cx) + .active_item() + .is_some_and(|item| item.downcast::().is_some()); + + if center_pane_has_focus && active_center_item_is_terminal { + let working_directory = default_working_directory(workspace, cx); + let local = action.local; + Self::add_center_terminal(workspace, window, cx, move |project, cx| { + if local { + project.create_local_terminal(cx) + } else { + project.create_terminal_shell(working_directory, cx) + } + }) + .detach_and_log_err(cx); + return; + } + + let Some(terminal_panel) = workspace.panel::(cx) else { + return; + }; + + terminal_panel + .update(cx, |this, cx| { + if action.local { + this.add_local_terminal_shell(RevealStrategy::Always, window, cx) + } else { + this.add_terminal_shell( + default_working_directory(workspace, cx), + RevealStrategy::Always, + window, + cx, + ) + } + }) + .detach_and_log_err(cx); + } + + fn terminals_for_task( + &self, + label: &str, + cx: &mut App, + ) -> Vec<(usize, Entity, Entity)> { + let Some(workspace) = self.workspace.upgrade() else { + return Vec::new(); + }; + + let pane_terminal_views = |pane: Entity| { + pane.read(cx) + .items() + .enumerate() + .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) + .filter_map(|(index, terminal_view)| { + let task_state = terminal_view.read(cx).terminal().read(cx).task()?; + if &task_state.spawned_task.full_label == label { + Some((index, terminal_view)) + } else { + None + } + }) + .map(move |(index, terminal_view)| (index, pane.clone(), terminal_view)) + }; + + self.center + .panes() + .into_iter() + .cloned() + .flat_map(pane_terminal_views) + .chain( + workspace + .read(cx) + .panes() + .iter() + .cloned() + .flat_map(pane_terminal_views), + ) + .sorted_by_key(|(_, _, terminal_view)| terminal_view.entity_id()) + .collect() + } + + fn activate_terminal_view( + &self, + pane: &Entity, + item_index: usize, + focus: bool, + window: &mut Window, + cx: &mut App, + ) { + pane.update(cx, |pane, cx| { + pane.activate_item(item_index, true, focus, window, cx) + }) + } + + pub fn add_center_terminal( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + create_terminal: impl FnOnce( + &mut Project, + &mut Context, + ) -> Task>> + + 'static, + ) -> Task>> { + if !is_enabled_in_workspace(workspace, cx) { + return Task::ready(Err(anyhow!( + "terminal not yet supported for remote projects" + ))); + } + let project = workspace.project().downgrade(); + cx.spawn_in(window, async move |workspace, cx| { + let terminal = project.update(cx, create_terminal)?.await?; + + workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + }); + // Don't steal focus from an open modal (e.g. the command palette): + // a background terminal can finish starting up after the user has + // moved on, and focusing it would dismiss whatever they opened. + let focus_item = !workspace.has_active_modal(window, cx); + workspace.add_item_to_active_pane( + Box::new(terminal_view), + None, + focus_item, + window, + cx, + ); + })?; + Ok(terminal.downgrade()) + }) + } + + pub fn add_terminal_task( + &mut self, + task: SpawnInTerminal, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |terminal_panel, cx| { + if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? { + anyhow::bail!("terminal not yet supported for remote projects"); + } + let pane = terminal_panel.update(cx, |terminal_panel, _| { + terminal_panel.pending_terminals_to_add += 1; + terminal_panel.active_pane.clone() + })?; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; + let terminal = project + .update(cx, |project, cx| project.create_terminal_task(task, cx)) + .await?; + let result = workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + })); + + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(window, cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(window, cx); + } + RevealStrategy::Never => {} + } + + pane.update(cx, |pane, cx| { + let focus = matches!(reveal_strategy, RevealStrategy::Always); + pane.add_item(terminal_view, true, focus, None, window, cx); + }); + + Ok(terminal.downgrade()) + })?; + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.pending_terminals_to_add = + terminal_panel.pending_terminals_to_add.saturating_sub(1); + terminal_panel.serialize(cx) + })?; + result + }) + } + + fn add_terminal_shell( + &mut self, + cwd: Option, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + self.add_terminal_shell_internal(false, cwd, reveal_strategy, window, cx) + } + + fn add_local_terminal_shell( + &mut self, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + self.add_terminal_shell_internal(true, None, reveal_strategy, window, cx) + } + + fn add_terminal_shell_internal( + &mut self, + force_local: bool, + cwd: Option, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |terminal_panel, cx| { + if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? { + anyhow::bail!("terminal not yet supported for collaborative projects"); + } + let pane = terminal_panel.update(cx, |terminal_panel, _| { + terminal_panel.pending_terminals_to_add += 1; + terminal_panel.active_pane.clone() + })?; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; + let terminal = if force_local { + project + .update(cx, |project, cx| project.create_local_terminal(cx)) + .await + } else { + project + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx)) + .await + }; + + match terminal { + Ok(terminal) => { + let result = workspace.update_in(cx, |workspace, window, cx| { + let terminal_view = Box::new(cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.weak_handle(), + workspace.database_id(), + workspace.project().downgrade(), + window, + cx, + ) + })); + + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(window, cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(window, cx); + } + RevealStrategy::Never => {} + } + + pane.update(cx, |pane, cx| { + let focus = matches!(reveal_strategy, RevealStrategy::Always); + pane.add_item(terminal_view, true, focus, None, window, cx); + }); + + Ok(terminal.downgrade()) + })?; + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.pending_terminals_to_add = + terminal_panel.pending_terminals_to_add.saturating_sub(1); + terminal_panel.serialize(cx) + })?; + result + } + Err(error) => { + pane.update_in(cx, |pane, window, cx| { + let focus = pane.has_focus(window, cx); + let failed_to_spawn = cx.new(|cx| FailedToSpawnTerminal { + error: error.to_string(), + focus_handle: cx.focus_handle(), + }); + pane.add_item(Box::new(failed_to_spawn), true, focus, None, window, cx); + })?; + Err(error) + } + } + }) + } + + fn serialize(&mut self, cx: &mut Context) { + let Some(serialization_key) = self + .workspace + .read_with(cx, |workspace, _| { + TerminalPanel::serialization_key(workspace) + }) + .ok() + .flatten() + else { + return; + }; + let kvp = KeyValueStore::global(cx); + self.pending_serialization = cx.spawn(async move |terminal_panel, cx| { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + let terminal_panel = terminal_panel.upgrade()?; + let items = terminal_panel.update(cx, |terminal_panel, cx| { + SerializedItems::WithSplits(serialize_pane_group( + &terminal_panel.center, + &terminal_panel.active_pane, + cx, + )) + }); + cx.background_spawn( + async move { + kvp.write_kvp( + serialization_key, + serde_json::to_string(&SerializedTerminalPanel { + items, + active_item_id: None, + })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ) + .await; + Some(()) + }); + } + + fn replace_terminal( + &self, + spawn_task: SpawnInTerminal, + task_pane: Entity, + terminal_item_index: usize, + terminal_to_replace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let reveal = spawn_task.reveal; + let task_workspace = self.workspace.clone(); + cx.spawn_in(window, async move |terminal_panel, cx| { + let project = terminal_panel.update(cx, |this, cx| { + this.workspace + .update(cx, |workspace, _| workspace.project().clone()) + })??; + let new_terminal = project + .update(cx, |project, cx| { + project.create_terminal_task(spawn_task, cx) + }) + .await?; + terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| { + terminal_to_replace.set_terminal(new_terminal.clone(), window, cx); + })?; + + let reveal_target = terminal_panel.update(cx, |panel, _| { + if panel.center.panes().iter().any(|p| **p == task_pane) { + RevealTarget::Dock + } else { + RevealTarget::Center + } + })?; + + match reveal { + RevealStrategy::Always => match reveal_target { + RevealTarget::Center => { + task_workspace.update_in(cx, |workspace, window, cx| { + let did_activate = workspace.activate_item( + &terminal_to_replace, + true, + true, + window, + cx, + ); + + anyhow::ensure!(did_activate, "Failed to retrieve terminal pane"); + + anyhow::Ok(()) + })??; + } + RevealTarget::Dock => { + terminal_panel.update_in(cx, |terminal_panel, window, cx| { + terminal_panel.activate_terminal_view( + &task_pane, + terminal_item_index, + true, + window, + cx, + ) + })?; + + cx.spawn(async move |cx| { + task_workspace + .update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx) + }) + .ok() + }) + .detach(); + } + }, + RevealStrategy::NoFocus => match reveal_target { + RevealTarget::Center => { + task_workspace.update_in(cx, |workspace, window, cx| { + workspace.active_pane().focus_handle(cx).focus(window, cx); + })?; + } + RevealTarget::Dock => { + terminal_panel.update_in(cx, |terminal_panel, window, cx| { + terminal_panel.activate_terminal_view( + &task_pane, + terminal_item_index, + false, + window, + cx, + ) + })?; + + cx.spawn(async move |cx| { + task_workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_panel::(window, cx) + }) + .ok() + }) + .detach(); + } + }, + RevealStrategy::Never => {} + } + + Ok(new_terminal.downgrade()) + }) + } + + fn has_no_terminals(&self, cx: &App) -> bool { + self.active_pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 + } + + pub fn assistant_enabled(&self) -> bool { + self.assistant_enabled + } + + /// Returns all panes in the terminal panel. + pub fn panes(&self) -> Vec<&Entity> { + self.center.panes() + } + + /// Returns all non-empty terminal selections from all terminal views in all panes. + pub fn terminal_selections(&self, cx: &App) -> Vec { + self.center + .panes() + .iter() + .flat_map(|pane| { + pane.read(cx).items().filter_map(|item| { + let terminal_view = item.downcast::()?; + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + .filter(|text| !text.is_empty()) + }) + }) + .collect() + } + + fn is_enabled(&self, cx: &App) -> bool { + self.workspace + .upgrade() + .is_some_and(|workspace| is_enabled_in_workspace(workspace.read(cx), cx)) + } + + fn activate_pane_in_direction( + &mut self, + direction: SplitDirection, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(pane) = self + .center + .find_pane_in_direction(&self.active_pane, direction, cx) + { + window.focus(&pane.focus_handle(cx), cx); + } else { + self.workspace + .update(cx, |workspace, cx| { + workspace.activate_pane_in_direction(direction, window, cx) + }) + .ok(); + } + } + + fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context) { + if let Some(to) = self + .center + .find_pane_in_direction(&self.active_pane, direction, cx) + .cloned() + { + self.center.swap(&self.active_pane, &to, cx); + cx.notify(); + } + } + + fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { + if self + .center + .move_to_border(&self.active_pane, direction, cx) + .unwrap() + { + cx.notify(); + } + } +} + +/// Prepares a `SpawnInTerminal` by computing the command, args, and command_label +/// based on the shell configuration. This is a pure function that can be tested +/// without spawning actual terminals. +pub fn prepare_task_for_spawn( + task: &SpawnInTerminal, + shell: &Shell, + is_windows: bool, +) -> SpawnInTerminal { + let builder = ShellBuilder::new(shell, is_windows); + let command_label = builder.command_label(task.command.as_deref().unwrap_or("")); + let (command, args) = builder.build_no_quote(task.command.clone(), &task.args); + + SpawnInTerminal { + command_label, + command: Some(command), + args, + ..task.clone() + } +} + +fn is_enabled_in_workspace(workspace: &Workspace, cx: &App) -> bool { + workspace.project().read(cx).supports_terminal(cx) +} + +pub fn new_terminal_pane( + workspace: WeakEntity, + project: Entity, + zoomed: bool, + window: &mut Window, + cx: &mut Context, +) -> Entity { + let terminal_panel = cx.entity(); + let pane = cx.new(|cx| { + let mut pane = Pane::new( + workspace.clone(), + project.clone(), + Default::default(), + None, + workspace::NewTerminal::default().boxed_clone(), + false, + window, + cx, + ); + pane.set_zoomed(zoomed, cx); + pane.set_can_navigate(false, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_, _| true); + pane.set_zoom_out_on_close(false); + + let split_closure_terminal_panel = terminal_panel.downgrade(); + pane.set_can_split(Some(Arc::new(move |pane, dragged_item, _window, cx| { + if let Some(tab) = dragged_item.downcast_ref::() { + let is_current_pane = tab.pane == cx.entity(); + let Some(can_drag_away) = split_closure_terminal_panel + .read_with(cx, |terminal_panel, _| { + let current_panes = terminal_panel.center.panes(); + !current_panes.contains(&&tab.pane) + || current_panes.len() > 1 + || (!is_current_pane || pane.items_len() > 1) + }) + .ok() + else { + return false; + }; + if can_drag_away { + let item = if is_current_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + return item.downcast::().is_some(); + } + } + } + false + }))); + + let toolbar = pane.toolbar().clone(); + if let Some(callbacks) = cx.try_global::() { + let languages = Some(project.read(cx).languages().clone()); + (callbacks.setup_search_bar)(languages, &toolbar, window, cx); + } + let breadcrumbs = cx.new(|_| Breadcrumbs::new()); + toolbar.update(cx, |toolbar, cx| { + toolbar.add_item(breadcrumbs, window, cx); + }); + + pane + }); + + cx.subscribe_in(&pane, window, TerminalPanel::handle_pane_event) + .detach(); + cx.observe(&pane, |_, _, cx| cx.notify()).detach(); + + pane +} + +async fn wait_for_terminals_tasks( + terminals_for_task: Vec<(usize, Entity, Entity)>, + cx: &mut AsyncApp, +) { + let pending_tasks = terminals_for_task.iter().map(|(_, _, terminal)| { + terminal.update(cx, |terminal_view, cx| { + terminal_view + .terminal() + .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx)) + }) + }); + join_all(pending_tasks).await; +} + +struct FailedToSpawnTerminal { + error: String, + focus_handle: FocusHandle, +} + +impl Focusable for FailedToSpawnTerminal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for FailedToSpawnTerminal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let popover_menu = PopoverMenu::new("settings-popover") + .trigger( + IconButton::new("icon-button-popover", IconName::ChevronDown) + .icon_size(IconSize::XSmall), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .action("Open Settings", zed_actions::OpenSettings.boxed_clone()) + .action( + "Edit settings.json", + zed_actions::OpenSettingsFile.boxed_clone(), + ) + })) + }) + .anchor(Anchor::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }); + + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .p_4() + .items_center() + .justify_center() + .bg(cx.theme().colors().editor_background) + .child( + v_flex() + .max_w_112() + .items_center() + .justify_center() + .text_center() + .child(Label::new("Failed to spawn terminal")) + .child( + Label::new(self.error.to_string()) + .size(LabelSize::Small) + .color(Color::Muted) + .mb_4(), + ) + .child(SplitButton::new( + ButtonLike::new("open-settings-ui") + .child(Label::new("Edit Settings").size(LabelSize::Small)) + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::OpenSettings.boxed_clone(), cx); + }), + popover_menu.into_any_element(), + )), + ) + } +} + +impl EventEmitter<()> for FailedToSpawnTerminal {} + +impl workspace::Item for FailedToSpawnTerminal { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + SharedString::new_static("Failed to spawn terminal") + } +} + +impl EventEmitter for TerminalPanel {} + +impl Render for TerminalPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let registrar = cx + .try_global::() + .map(|callbacks| { + (callbacks.wrap_div_with_search_actions)(div(), self.active_pane.clone()) + }) + .unwrap_or_else(div); + self.workspace + .update(cx, |workspace, cx| { + registrar + .track_focus(&self.focus_handle) + .size_full() + .child(self.center.render( + workspace.zoomed_item(), + None, + &workspace::PaneRenderContext { + follower_states: &HashMap::default(), + active_call: workspace.active_call(), + active_pane: &self.active_pane, + app_state: workspace.app_state(), + project: workspace.project(), + workspace: &workspace.weak_handle(), + }, + window, + cx, + )) + }) + .ok() + .map(|div| { + div.on_action({ + cx.listener(|terminal_panel, _: &ActivatePaneLeft, window, cx| { + terminal_panel.activate_pane_in_direction(SplitDirection::Left, window, cx); + }) + }) + .on_action({ + cx.listener(|terminal_panel, _: &ActivatePaneRight, window, cx| { + terminal_panel.activate_pane_in_direction( + SplitDirection::Right, + window, + cx, + ); + }) + }) + .on_action({ + cx.listener(|terminal_panel, _: &ActivatePaneUp, window, cx| { + terminal_panel.activate_pane_in_direction(SplitDirection::Up, window, cx); + }) + }) + .on_action({ + cx.listener(|terminal_panel, _: &ActivatePaneDown, window, cx| { + terminal_panel.activate_pane_in_direction(SplitDirection::Down, window, cx); + }) + }) + .on_action( + cx.listener(|terminal_panel, _action: &ActivateNextPane, window, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let next_ix = (ix + 1) % panes.len(); + window.focus(&panes[next_ix].focus_handle(cx), cx); + } + }), + ) + .on_action(cx.listener( + |terminal_panel, _action: &ActivatePreviousPane, window, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); + window.focus(&panes[prev_ix].focus_handle(cx), cx); + } + }, + )) + .on_action( + cx.listener(|terminal_panel, action: &ActivatePane, window, cx| { + let panes = terminal_panel.center.panes(); + if let Some(&pane) = panes.get(action.0) { + window.focus(&pane.read(cx).focus_handle(cx), cx); + } else { + let future = + terminal_panel.new_pane_with_active_terminal(true, window, cx); + cx.spawn_in(window, async move |terminal_panel, cx| { + if let Some(new_pane) = future.await { + _ = terminal_panel.update_in( + cx, + |terminal_panel, window, cx| { + terminal_panel.center.split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + cx, + ); + let new_pane = new_pane.read(cx); + window.focus(&new_pane.focus_handle(cx), cx); + }, + ); + } + }) + .detach(); + } + }), + ) + .on_action(cx.listener(|terminal_panel, _: &SwapPaneLeft, _, cx| { + terminal_panel.swap_pane_in_direction(SplitDirection::Left, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &SwapPaneRight, _, cx| { + terminal_panel.swap_pane_in_direction(SplitDirection::Right, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &SwapPaneUp, _, cx| { + terminal_panel.swap_pane_in_direction(SplitDirection::Up, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &SwapPaneDown, _, cx| { + terminal_panel.swap_pane_in_direction(SplitDirection::Down, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneLeft, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Left, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneRight, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Right, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneUp, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Up, cx); + })) + .on_action(cx.listener(|terminal_panel, _: &MovePaneDown, _, cx| { + terminal_panel.move_pane_to_border(SplitDirection::Down, cx); + })) + .on_action( + cx.listener(|terminal_panel, action: &MoveItemToPane, window, cx| { + let Some(&target_pane) = + terminal_panel.center.panes().get(action.destination) + else { + return; + }; + move_active_item( + &terminal_panel.active_pane, + target_pane, + action.focus, + true, + window, + cx, + ); + }), + ) + .on_action(cx.listener( + |terminal_panel, action: &MoveItemToPaneInDirection, window, cx| { + let source_pane = &terminal_panel.active_pane; + if let Some(destination_pane) = terminal_panel + .center + .find_pane_in_direction(source_pane, action.direction, cx) + { + move_active_item( + source_pane, + destination_pane, + action.focus, + true, + window, + cx, + ); + }; + }, + )) + }) + .unwrap_or_else(|| div()) + } +} + +impl Focusable for TerminalPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for TerminalPanel { + fn activation_focus_handle(&self, cx: &App) -> FocusHandle { + self.active_pane.focus_handle(cx) + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + TerminalSettings::get_global(cx).dock.into() + } + + fn position_is_valid(&self, _: DockPosition) -> bool { + true + } + + fn starts_open(&self, _: &Window, cx: &App) -> bool { + TerminalSettings::get_global(cx).starts_open + } + + fn set_position( + &mut self, + position: DockPosition, + _window: &mut Window, + cx: &mut Context, + ) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + let dock = match position { + DockPosition::Left => TerminalDockPosition::Left, + DockPosition::Bottom => TerminalDockPosition::Bottom, + DockPosition::Right => TerminalDockPosition::Right, + }; + settings.terminal.get_or_insert_default().dock = Some(dock); + }); + } + + fn default_size(&self, window: &Window, cx: &App) -> Pixels { + let settings = TerminalSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => settings.default_height, + } + } + + fn supports_flexible_size(&self) -> bool { + true + } + + fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { + TerminalSettings::get_global(cx).flexible + } + + fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.terminal.get_or_insert_default().flexible = Some(flexible); + }); + } + + fn is_zoomed(&self, _window: &Window, cx: &App) -> bool { + self.active_pane.read(cx).is_zoomed() + } + + fn set_zoomed(&mut self, zoomed: bool, _: &mut Window, cx: &mut Context) { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(zoomed, cx); + }) + } + cx.notify(); + } + + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + let old_active = self.active; + self.active = active; + if !active || old_active == active || !self.has_no_terminals(cx) { + return; + } + cx.defer_in(window, |this, window, cx| { + let Ok(kind) = this + .workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + else { + return; + }; + + this.add_terminal_shell(kind, RevealStrategy::Always, window, cx) + .detach_and_log_err(cx) + }) + } + + fn icon_label(&self, _window: &Window, cx: &App) -> Option { + if !TerminalSettings::get_global(cx).show_count_badge { + return None; + } + let count = self + .center + .panes() + .into_iter() + .map(|pane| pane.read(cx).items_len()) + .sum::(); + if count == 0 { + None + } else { + Some(count.to_string()) + } + } + + fn persistent_name() -> &'static str { + "TerminalPanel" + } + + fn panel_key() -> &'static str { + TERMINAL_PANEL_KEY + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + if (self.is_enabled(cx) || !self.has_no_terminals(cx)) + && TerminalSettings::get_global(cx).button + { + Some(IconName::TerminalAlt) + } else { + None + } + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Terminal Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(Toggle) + } + + fn pane(&self) -> Option> { + Some(self.active_pane.clone()) + } + + fn activation_priority(&self) -> u32 { + 2 + } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.terminal.get_or_insert_default().button = Some(false); + })) + } +} + +struct TerminalProvider(Entity); + +impl workspace::TerminalProvider for TerminalProvider { + fn spawn( + &self, + task: SpawnInTerminal, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let terminal_panel = self.0.clone(); + window.spawn(cx, async move |cx| { + let terminal = terminal_panel + .update_in(cx, |terminal_panel, window, cx| { + terminal_panel.spawn_task(&task, window, cx) + }) + .ok()? + .await; + match terminal { + Ok(terminal) => { + let exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx)) + .ok()? + .await?; + Some(Ok(exit_status)) + } + Err(e) => Some(Err(e)), + } + }) + } +} + +#[derive(IntoElement)] +struct InlineAssistTabBarButton { + focus_handle: FocusHandle, +} + +impl RenderOnce for InlineAssistTabBarButton { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let focus_handle = self.focus_handle; + IconButton::new("terminal_inline_assistant", IconName::ZedAssistant) + .icon_size(IconSize::Small) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_, window, cx| { + focus_handle.dispatch_action(&InlineAssist::default(), window, cx); + } + }) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Inline Assist", &InlineAssist::default(), &focus_handle, cx) + }) + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZero; + + use super::*; + use gpui::{Modifiers, TestAppContext, UpdateGlobal as _, VisualTestContext}; + use pretty_assertions::assert_eq; + use project::FakeFs; + use settings::SettingsStore; + use workspace::MultiWorkspace; + + #[test] + fn test_prepare_empty_task() { + let input = SpawnInTerminal::default(); + let shell = Shell::System; + + let result = prepare_task_for_spawn(&input, &shell, false); + + let expected_shell = util::get_system_shell(); + assert_eq!(result.env, HashMap::default()); + assert_eq!(result.cwd, None); + assert_eq!(result.shell, Shell::System); + assert_eq!( + result.command, + Some(expected_shell.clone()), + "Empty tasks should spawn a -i shell" + ); + assert_eq!(result.args, Vec::::new()); + assert_eq!( + result.command_label, expected_shell, + "We show the shell launch for empty commands" + ); + } + + #[gpui::test] + async fn test_bypass_max_tabs_limit(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) + }) + .unwrap(); + + set_max_tabs(cx, Some(3)); + + for _ in 0..5 { + let task = window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .unwrap(); + task.await.unwrap(); + } + + cx.run_until_parked(); + + let item_count = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + item_count, 5, + "Terminal panel should bypass max_tabs limit and have all 5 terminals" + ); + } + + #[cfg(unix)] + #[test] + fn test_prepare_script_like_task() { + let user_command = r#"REPO_URL=$(git remote get-url origin | sed -e \"s/^git@\\(.*\\):\\(.*\\)\\.git$/https:\\/\\/\\1\\/\\2/\"); COMMIT_SHA=$(git log -1 --format=\"%H\" -- \"${ZED_RELATIVE_FILE}\"); echo \"${REPO_URL}/blob/${COMMIT_SHA}/${ZED_RELATIVE_FILE}#L${ZED_ROW}-$(echo $(($(wc -l <<< \"$ZED_SELECTED_TEXT\") + $ZED_ROW - 1)))\" | xclip -selection clipboard"#.to_string(); + let expected_cwd = PathBuf::from("/some/work"); + + let input = SpawnInTerminal { + command: Some(user_command.clone()), + cwd: Some(expected_cwd.clone()), + ..SpawnInTerminal::default() + }; + let shell = Shell::System; + + let result = prepare_task_for_spawn(&input, &shell, false); + + let system_shell = util::get_system_shell(); + assert_eq!(result.env, HashMap::default()); + assert_eq!(result.cwd, Some(expected_cwd)); + assert_eq!(result.shell, Shell::System); + assert_eq!(result.command, Some(system_shell.clone())); + assert_eq!( + result.args, + vec!["-i".to_string(), "-c".to_string(), user_command.clone()], + "User command should have been moved into the arguments, as we're spawning a new -i shell", + ); + assert_eq!( + result.command_label, + format!( + "{system_shell} {interactive}-c '{user_command}'", + interactive = if cfg!(windows) { "" } else { "-i " } + ), + "We want to show to the user the entire command spawned" + ); + } + + #[gpui::test] + async fn renders_error_if_default_shell_fails(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.terminal.get_or_insert_default().project.shell = + Some(settings::Shell::Program("__nonexistent_shell__".to_owned())); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) + }) + .unwrap(); + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .unwrap() + .await + .unwrap_err(); + + window_handle + .update(cx, |_, _, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + assert!( + terminal_panel + .active_pane + .read(cx) + .items() + .any(|item| item.downcast::().is_some()), + "should spawn `FailedToSpawnTerminal` pane" + ); + }) + }) + .unwrap(); + } + + #[gpui::test] + async fn test_local_terminal_in_local_project(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) + }) + .unwrap(); + + let result = window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.add_local_terminal_shell(RevealStrategy::Always, window, cx) + }) + }) + .unwrap() + .await; + + assert!( + result.is_ok(), + "local terminal should successfully create in local project" + ); + } + + struct FocusOnlyModal { + focus_handle: gpui::FocusHandle, + } + impl gpui::EventEmitter for FocusOnlyModal {} + impl gpui::Focusable for FocusOnlyModal { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } + } + impl Render for FocusOnlyModal { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::div().track_focus(&self.focus_handle) + } + } + impl workspace::ModalView for FocusOnlyModal {} + + async fn open_center_display_terminal( + workspace: &Entity, + cx: &mut VisualTestContext, + ) { + workspace + .update_in(cx, |workspace, window, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |_, cx| { + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + terminal::terminal_settings::CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + util::paths::PathStyle::local(), + ) + .subscribe(cx) + }); + gpui::Task::ready(Ok(terminal)) + }) + }) + .await + .unwrap(); + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_center_terminal_keeps_focus_on_active_modal(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window_handle + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + let modal_focus_handle = workspace.update_in(cx, |workspace, window, cx| { + let focus_handle = cx.focus_handle(); + workspace.toggle_modal(window, cx, { + let focus_handle = focus_handle.clone(); + move |_, _| FocusOnlyModal { focus_handle } + }); + focus_handle + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(workspace.has_active_modal(window, cx)); + assert!( + modal_focus_handle.is_focused(window), + "the modal should hold focus before the terminal is created" + ); + }); + + open_center_display_terminal(&workspace, cx).await; + + workspace.update_in(cx, |_, window, _| { + assert!( + modal_focus_handle.is_focused(window), + "a background center terminal must not steal focus from an active modal" + ); + }); + } + + #[gpui::test] + async fn test_center_terminal_takes_focus_without_modal(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window_handle + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + open_center_display_terminal(&workspace, cx).await; + + workspace.update_in(cx, |workspace, window, cx| { + assert!(!workspace.has_active_modal(window, cx)); + let terminal_view = workspace + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("the new center terminal should be the active item"); + assert!( + terminal_view.focus_handle(cx).contains_focused(window, cx), + "with no modal open, a new center terminal should take focus" + ); + }); + } + + #[gpui::test] + async fn test_inline_assist_tooltip_shows_keybinding_of_active_terminal( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + cx.update(|cx| { + cx.bind_keys([gpui::KeyBinding::new( + "ctrl-enter", + InlineAssist::default(), + Some("Terminal"), + )]) + }); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + + terminal_panel.update(cx, |panel, cx| panel.set_assistant_enabled(true, cx)); + terminal_panel + .update_in(cx, |panel, window, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let button_bounds = cx + .debug_bounds("ICON-ZedAssistant") + .expect("inline assist button should be rendered in the terminal tab bar"); + cx.simulate_mouse_move(button_bounds.center(), None, Modifiers::default()); + + cx.executor().advance_clock(Duration::from_millis(600)); + cx.run_until_parked(); + + assert!( + cx.debug_bounds("KEY_BINDING-enter").is_some(), + "tooltip should show the InlineAssist keybinding resolved in the terminal's context" + ); + } + + async fn init_workspace_with_panel( + cx: &mut TestAppContext, + ) -> (gpui::WindowHandle, Entity) { + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + }) + .expect("Failed to initialize workspace with terminal panel"); + + (window_handle, terminal_panel) + } + + #[gpui::test] + async fn test_terminal_panel_starts_open_follows_setting(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + assert!( + !terminal_panel.starts_open(window, cx), + "terminal panel should not start open by default" + ); + }); + }) + .expect("Failed to read terminal panel starts_open default"); + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.terminal.get_or_insert_default().starts_open = Some(true); + }); + }); + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + assert!( + terminal_panel.starts_open(window, cx), + "terminal panel should start open when configured" + ); + }); + }) + .expect("Failed to read configured terminal panel starts_open"); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_by_default(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "Terminal should be added to the panel when no center terminal is focused" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_center_when_center_terminal_focused( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + assert_eq!(center_items_before, 1, "Center pane should have 1 terminal"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Center pane should have an active item"); + let terminal_view = active_item + .downcast::() + .expect("Active center item should be a TerminalView"); + window.focus(&terminal_view.focus_handle(cx), cx); + }) + }) + .expect("Failed to focus terminal view"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + center_items_after, + center_items_before + 1, + "New terminal should be added to the center pane" + ); + assert_eq!( + panel_items_after, panel_items_before, + "Terminal panel should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_when_panel_focused(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create panel terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + window.focus(&terminal_panel.read(cx).focus_handle(cx), cx); + }) + .expect("Failed to focus terminal panel"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "New terminal should be added to the panel when panel is focused" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_local_terminal_opens_in_center_when_center_terminal_focused( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Center pane should have an active item"); + let terminal_view = active_item + .downcast::() + .expect("Active center item should be a TerminalView"); + window.focus(&terminal_view.focus_handle(cx), cx); + }) + }) + .expect("Failed to focus terminal view"); + cx.run_until_parked(); + + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal { local: true }, + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal with local=true"); + cx.run_until_parked(); + + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + + assert_eq!( + center_items_after, + center_items_before + 1, + "New local terminal should be added to the center pane" + ); + assert_eq!( + panel_items_after, panel_items_before, + "Terminal panel should not gain a new terminal" + ); + } + + #[gpui::test] + async fn test_new_terminal_opens_in_panel_when_panel_focused_and_center_has_terminal( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + init_test(cx); + + let (window_handle, terminal_panel) = init_workspace_with_panel(cx).await; + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { + project.create_terminal_shell(None, cx) + }) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create center terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |panel, cx| { + panel.add_terminal_shell(None, RevealStrategy::Always, window, cx) + }) + }) + .expect("Failed to update workspace") + .await + .expect("Failed to create panel terminal"); + cx.run_until_parked(); + + window_handle + .update(cx, |_, window, cx| { + window.focus(&terminal_panel.read(cx).focus_handle(cx), cx); + }) + .expect("Failed to focus terminal panel"); + cx.run_until_parked(); + + let panel_items_before = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_before = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + TerminalPanel::new_terminal( + workspace, + &workspace::NewTerminal::default(), + window, + cx, + ); + }) + }) + .expect("Failed to dispatch new_terminal"); + cx.run_until_parked(); + + let panel_items_after = + terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len()); + let center_items_after = window_handle + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .items_len() + }) + .expect("Failed to read center pane items"); + + assert_eq!( + panel_items_after, + panel_items_before + 1, + "New terminal should go to panel when panel is focused, even if center has a terminal" + ); + assert_eq!( + center_items_after, center_items_before, + "Center pane should not gain a new terminal when panel is focused" + ); + } + + fn set_max_tabs(cx: &mut TestAppContext, value: Option) { + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.max_tabs = value.map(|v| NonZero::new(v).unwrap()) + }); + }); + } + + pub fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } +} diff --git a/vendor/zed-terminal-view/src/terminal_path_like_target.rs b/vendor/zed-terminal-view/src/terminal_path_like_target.rs new file mode 100644 index 00000000..573041ee --- /dev/null +++ b/vendor/zed-terminal-view/src/terminal_path_like_target.rs @@ -0,0 +1,1013 @@ +use super::{HoverTarget, HoveredWord, TerminalView}; +use anyhow::Result; +use editor::items::open_resolved_target; +use gpui::{Context, Task, TaskExt, WeakEntity, Window}; +use std::path::PathBuf; +use terminal::PathLikeTarget; +use workspace::path_link::PathMatching; +#[cfg(not(test))] +use workspace::path_link::resolve_open_target; +#[cfg(test)] +use workspace::path_link::{ + BackgroundPathChecks, OpenTargetFoundBy, resolve_open_target_with_fs_checks, +}; +use workspace::{Workspace, path_link::OpenTarget}; + +pub(super) fn hover_path_like_target( + workspace: &WeakEntity, + hovered_word: HoveredWord, + path_like_target: &PathLikeTarget, + cx: &mut Context, +) -> Task<()> { + #[cfg(not(test))] + { + possible_hover_target(workspace, hovered_word, path_like_target, cx) + } + #[cfg(test)] + { + possible_hover_target( + workspace, + hovered_word, + path_like_target, + cx, + BackgroundPathChecks::LocalFileSystem, + ) + } +} + +fn possible_hover_target( + workspace: &WeakEntity, + hovered_word: HoveredWord, + path_like_target: &PathLikeTarget, + cx: &mut Context, + #[cfg(test)] background_path_checks: BackgroundPathChecks, +) -> Task<()> { + #[cfg(not(test))] + let file_to_open_task = resolve_open_target( + workspace, + PathMatching::Heuristic, + &path_like_target.maybe_path, + path_like_target.working_directory.as_deref(), + cx, + ); + #[cfg(test)] + let file_to_open_task = resolve_open_target_with_fs_checks( + workspace, + PathMatching::Heuristic, + &path_like_target.maybe_path, + path_like_target.working_directory.as_deref(), + cx, + background_path_checks, + ); + cx.spawn(async move |terminal_view, cx| { + let file_to_open = file_to_open_task.await; + terminal_view + .update(cx, |terminal_view, cx| { + match file_to_open { + Some(OpenTarget::Path(path, ..) | OpenTarget::Worktree(path, ..)) => { + terminal_view.hover = Some(HoverTarget { + tooltip: path + .to_string(&|path: &PathBuf| path.to_string_lossy().into_owned()), + hovered_word, + }); + } + None => { + terminal_view.hover = None; + } + }; + cx.notify(); + }) + .ok(); + }) +} + +pub(super) fn open_path_like_target( + workspace: &WeakEntity, + terminal_view: &mut TerminalView, + path_like_target: &PathLikeTarget, + window: &mut Window, + cx: &mut Context, +) { + #[cfg(not(test))] + { + possibly_open_target(workspace, terminal_view, path_like_target, window, cx) + .detach_and_log_err(cx) + } + #[cfg(test)] + { + possibly_open_target( + workspace, + terminal_view, + path_like_target, + window, + cx, + BackgroundPathChecks::LocalFileSystem, + ) + .detach_and_log_err(cx) + } +} + +fn possibly_open_target( + workspace: &WeakEntity, + terminal_view: &mut TerminalView, + path_like_target: &PathLikeTarget, + window: &mut Window, + cx: &mut Context, + #[cfg(test)] background_path_checks: BackgroundPathChecks, +) -> Task>> { + if terminal_view.hover.is_none() { + return Task::ready(Ok(None)); + } + let workspace = workspace.clone(); + let path_like_target = path_like_target.clone(); + cx.spawn_in(window, async move |terminal_view, cx| { + let Some(open_target) = terminal_view + .update(cx, |_, cx| { + #[cfg(not(test))] + { + resolve_open_target( + &workspace, + PathMatching::Heuristic, + &path_like_target.maybe_path, + path_like_target.working_directory.as_deref(), + cx, + ) + } + #[cfg(test)] + { + resolve_open_target_with_fs_checks( + &workspace, + PathMatching::Heuristic, + &path_like_target.maybe_path, + path_like_target.working_directory.as_deref(), + cx, + background_path_checks, + ) + } + })? + .await + else { + return Ok(None); + }; + + let opened = open_resolved_target(&workspace, &open_target, cx).await?; + Ok(opened.then_some(open_target)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, TestAppContext}; + use project::Project; + use serde_json::json; + use std::path::{Path, PathBuf}; + use terminal::{ + HoveredWord, Point, Range, TerminalBuilder, + terminal_settings::{AlternateScroll, CursorShape}, + }; + use util::path; + use util::paths::PathStyle; + use workspace::{AppState, MultiWorkspace}; + + async fn init_test( + app_cx: &mut TestAppContext, + trees: impl IntoIterator, + worktree_roots: impl IntoIterator, + ) -> impl AsyncFnMut( + HoveredWord, + PathLikeTarget, + BackgroundPathChecks, + ) -> (Option, Option) { + let fs = app_cx.update(AppState::test).fs.as_fake().clone(); + + app_cx.update(|cx| { + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + }); + + for (path, tree) in trees { + fs.insert_tree(path, tree).await; + } + + let project: gpui::Entity = Project::test( + fs.clone(), + worktree_roots.into_iter().map(Path::new), + app_cx, + ) + .await; + + let (multi_workspace, cx) = app_cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let terminal = app_cx.new(|cx| { + TerminalBuilder::new_display_only( + CursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + + let workspace_a = workspace.clone(); + let (terminal_view, cx) = app_cx.add_window_view(|window, cx| { + TerminalView::new( + terminal, + workspace_a.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + async move |hovered_word: HoveredWord, + path_like_target: PathLikeTarget, + background_path_checks: BackgroundPathChecks| + -> (Option, Option) { + let workspace_a = workspace.clone(); + terminal_view + .update(cx, |_, cx| { + possible_hover_target( + &workspace_a.downgrade(), + hovered_word, + &path_like_target, + cx, + background_path_checks, + ) + }) + .await; + + let hover_target = + terminal_view.read_with(cx, |terminal_view, _| terminal_view.hover.clone()); + + let open_target = terminal_view + .update_in(cx, |terminal_view, window, cx| { + possibly_open_target( + &workspace.downgrade(), + terminal_view, + &path_like_target, + window, + cx, + background_path_checks, + ) + }) + .await + .expect("Failed to possibly open target"); + + (hover_target, open_target) + } + } + + async fn test_path_like_simple( + test_path_like: &mut impl AsyncFnMut( + HoveredWord, + PathLikeTarget, + BackgroundPathChecks, + ) -> (Option, Option), + maybe_path: &str, + tooltip: &str, + working_directory: Option, + background_path_checks: BackgroundPathChecks, + open_target_found_by: OpenTargetFoundBy, + file: &str, + line: u32, + ) { + let (hover_target, open_target) = test_path_like( + HoveredWord { + word: maybe_path.to_string(), + word_match: Range::new(Point::new(0, 0), Point::new(0, 0)), + id: 0, + }, + PathLikeTarget { + maybe_path: maybe_path.to_string(), + working_directory, + }, + background_path_checks, + ) + .await; + + let Some(hover_target) = hover_target else { + assert!( + hover_target.is_some(), + "Hover target should not be `None` at {file}:{line}:" + ); + return; + }; + + assert_eq!( + hover_target.tooltip, tooltip, + "Tooltip mismatch at {file}:{line}:" + ); + assert_eq!( + hover_target.hovered_word.word, maybe_path, + "Hovered word mismatch at {file}:{line}:" + ); + + let Some(open_target) = open_target else { + assert!( + open_target.is_some(), + "Open target should not be `None` at {file}:{line}:" + ); + return; + }; + + assert_eq!( + open_target.path().path, + Path::new(tooltip), + "Open target path mismatch at {file}:{line}:" + ); + + assert_eq!( + open_target.found_by(), + open_target_found_by, + "Open target found by mismatch at {file}:{line}:" + ); + } + + macro_rules! none_or_some_pathbuf { + (None) => { + None + }; + ($cwd:literal) => { + Some($crate::PathBuf::from(path!($cwd))) + }; + } + + macro_rules! test_path_like { + ( + $test_path_like:expr, + $maybe_path:literal, + $tooltip:literal, + $cwd:tt, + $found_by:expr + ) => {{ + test_path_like!( + $test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::LocalFileSystem, + $found_by + ); + test_path_like!( + $test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::ProjectPathResolution, + $found_by + ); + }}; + + ( + $test_path_like:expr, + $maybe_path:literal, + $tooltip:literal, + $cwd:tt, + $background_fs_checks:path, + $found_by:expr + ) => { + test_path_like_simple( + &mut $test_path_like, + path!($maybe_path), + path!($tooltip), + none_or_some_pathbuf!($cwd), + $background_fs_checks, + $found_by, + std::file!(), + std::line!(), + ) + .await + }; + } + + // Note the arms of `test`, `test_local`, and `test_remote` should be collapsed once macro + // metavariable expressions (#![feature(macro_metavar_expr)]) are stabilized. + // See https://github.com/rust-lang/rust/issues/83527 + #[doc = "test_path_likes!(, , , { $(;)+ })"] + macro_rules! test_path_likes { + ($cx:expr, $trees:expr, $worktrees:expr, { $($tests:expr;)+ }) => { { + let mut test_path_like = init_test($cx, $trees, $worktrees).await; + #[doc ="test!(, , "] + #[doc ="\\[, found by \\])"] + #[allow(unused_macros)] + macro_rules! test { + ($maybe_path:literal, $tooltip:literal, $cwd:tt) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + OpenTargetFoundBy::WorktreeExact + ) + }; + ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + OpenTargetFoundBy::$found_by + ) + } + } + #[doc ="test_local!(, , "] + #[doc ="\\[, found by \\])"] + #[allow(unused_macros)] + macro_rules! test_local { + ($maybe_path:literal, $tooltip:literal, $cwd:tt) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::LocalFileSystem, + OpenTargetFoundBy::WorktreeExact + ) + }; + ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::LocalFileSystem, + OpenTargetFoundBy::$found_by + ) + } + } + #[doc ="test_remote!(, , "] + #[doc ="\\[, found by \\])"] + #[allow(unused_macros)] + macro_rules! test_remote { + ($maybe_path:literal, $tooltip:literal, $cwd:tt) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::ProjectPathResolution, + OpenTargetFoundBy::WorktreeExact + ) + }; + ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => { + test_path_like!( + test_path_like, + $maybe_path, + $tooltip, + $cwd, + BackgroundPathChecks::ProjectPathResolution, + OpenTargetFoundBy::$found_by + ) + } + } + $($tests);+ + } } + } + + #[gpui::test] + async fn one_folder_worktree(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/test"), + json!({ + "lib.rs": "", + "test.rs": "", + }), + )], + vec![path!("/test")], + { + test!("lib.rs", "/test/lib.rs", None); + test!("/test/lib.rs", "/test/lib.rs", None); + test!("test.rs", "/test/test.rs", None); + test!("/test/test.rs", "/test/test.rs", None); + } + ) + } + + #[gpui::test] + async fn mixed_worktrees(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "lib.rs": "", + "test.rs": "", + "file.txt": "", + }), + ), + ], + vec![path!("/file.txt"), path!("/test")], + { + test!("file.txt", "/file.txt", "/"); + test!("/file.txt", "/file.txt", "/"); + + test!("lib.rs", "/test/lib.rs", "/test"); + test!("test.rs", "/test/test.rs", "/test"); + test!("file.txt", "/test/file.txt", "/test"); + + test!("/test/lib.rs", "/test/lib.rs", "/test"); + test!("/test/test.rs", "/test/test.rs", "/test"); + test!("/test/file.txt", "/test/file.txt", "/test"); + } + ) + } + + #[gpui::test] + async fn worktree_file_preferred(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "file.txt": "", + }), + ), + ], + vec![path!("/test")], + { + test!("file.txt", "/test/file.txt", "/test"); + } + ) + } + + mod issues { + use super::*; + + // https://github.com/zed-industries/zed/issues/28407 + #[gpui::test] + async fn issue_28407_siblings(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/dir1"), + json!({ + "dir 2": { + "C.py": "" + }, + "dir 3": { + "C.py": "" + }, + }), + )], + vec![path!("/dir1")], + { + test!("C.py", "/dir1/dir 2/C.py", "/dir1", WorktreeScan); + test!("C.py", "/dir1/dir 2/C.py", "/dir1/dir 2"); + test!("C.py", "/dir1/dir 3/C.py", "/dir1/dir 3"); + } + ) + } + + // https://github.com/zed-industries/zed/issues/28407 + // See https://github.com/zed-industries/zed/issues/34027 + // See https://github.com/zed-industries/zed/issues/33498 + #[gpui::test] + async fn issue_28407_nesting(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/project"), + json!({ + "lib": { + "src": { + "main.rs": "", + "only_in_lib.rs": "" + }, + }, + "src": { + "main.rs": "" + }, + }), + )], + vec![path!("/project")], + { + test!("main.rs", "/project/src/main.rs", "/project/src"); + test!("main.rs", "/project/lib/src/main.rs", "/project/lib/src"); + + test!("src/main.rs", "/project/src/main.rs", "/project"); + test!("src/main.rs", "/project/src/main.rs", "/project/src"); + test!("src/main.rs", "/project/lib/src/main.rs", "/project/lib"); + + test!("lib/src/main.rs", "/project/lib/src/main.rs", "/project"); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/src" + ); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/lib" + ); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/lib/src" + ); + test!( + "src/only_in_lib.rs", + "/project/lib/src/only_in_lib.rs", + "/project/lib/src", + WorktreeScan + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/28339 + #[gpui::test] + async fn issue_28339(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/tmp"), + json!({ + "issue28339": { + "foo": { + "bar.txt": "" + }, + }, + }), + )], + vec![path!("/tmp")], + { + test_local!( + "foo/./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339", + WorktreeExact + ); + test_local!( + "foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339", + WorktreeExact + ); + test_local!( + "foo/..///foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339", + WorktreeExact + ); + test_local!( + "issue28339/../issue28339/foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339", + WorktreeExact + ); + test_local!( + "./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo", + WorktreeExact + ); + test_local!( + "../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo", + WorktreeExact + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/28339 + #[gpui::test] + async fn issue_28339_remote(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/tmp"), + json!({ + "issue28339": { + "foo": { + "bar.txt": "" + }, + }, + }), + )], + vec![path!("/tmp")], + { + test_remote!( + "foo/./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test_remote!( + "foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test_remote!( + "foo/..///foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test_remote!( + "issue28339/../issue28339/foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test_remote!( + "./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo" + ); + test_remote!( + "../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo" + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + async fn issue_34027(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/tmp/issue34027"), + json!({ + "test.txt": "", + "foo": { + "test.txt": "", + } + }), + ),], + vec![path!("/tmp/issue34027")], + { + test!("test.txt", "/tmp/issue34027/test.txt", "/tmp/issue34027"); + test!( + "test.txt", + "/tmp/issue34027/foo/test.txt", + "/tmp/issue34027/foo" + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + async fn issue_34027_siblings(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/test"), + json!({ + "sub1": { + "file.txt": "", + }, + "sub2": { + "file.txt": "", + } + }), + ),], + vec![path!("/test")], + { + test!("file.txt", "/test/sub1/file.txt", "/test/sub1"); + test!("file.txt", "/test/sub2/file.txt", "/test/sub2"); + test!("sub1/file.txt", "/test/sub1/file.txt", "/test/sub1"); + test!("sub2/file.txt", "/test/sub2/file.txt", "/test/sub2"); + test!("sub1/file.txt", "/test/sub1/file.txt", "/test/sub2"); + test!("sub2/file.txt", "/test/sub2/file.txt", "/test/sub1"); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + async fn issue_34027_nesting(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/test"), + json!({ + "sub1": { + "file.txt": "", + "subsub1": { + "file.txt": "", + } + }, + "sub2": { + "file.txt": "", + "subsub1": { + "file.txt": "", + } + } + }), + ),], + vec![path!("/test")], + { + test!( + "file.txt", + "/test/sub1/subsub1/file.txt", + "/test/sub1/subsub1" + ); + test!( + "file.txt", + "/test/sub2/subsub1/file.txt", + "/test/sub2/subsub1" + ); + test!( + "subsub1/file.txt", + "/test/sub1/subsub1/file.txt", + "/test", + WorktreeScan + ); + test!( + "subsub1/file.txt", + "/test/sub1/subsub1/file.txt", + "/test", + WorktreeScan + ); + test!( + "subsub1/file.txt", + "/test/sub1/subsub1/file.txt", + "/test/sub1" + ); + test!( + "subsub1/file.txt", + "/test/sub2/subsub1/file.txt", + "/test/sub2" + ); + test!( + "subsub1/file.txt", + "/test/sub1/subsub1/file.txt", + "/test/sub1/subsub1", + WorktreeScan + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + async fn issue_34027_non_worktree_local_file(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "file.txt": "", + }), + ), + ], + vec![path!("/test")], + { + // Note: Opening a non-worktree file adds that file as a single file worktree. + test_local!("file.txt", "/file.txt", "/", BackgroundPathResolution); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + async fn issue_34027_non_worktree_remote_file(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "file.txt": "", + }), + ), + ], + vec![path!("/test")], + { + // Note: Opening a non-worktree file adds that file as a single file worktree. + test_remote!("file.txt", "/file.txt", "/", BackgroundPathResolution); + test_remote!("/test/file.txt", "/test/file.txt", "/"); + } + ) + } + + // https://github.com/zed-industries/zed/issues/39159 + #[gpui::test] + async fn issue_39159_remote_absolute_path_outside_worktree(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/tmp"), + json!({ + "a.txt": "", + }), + ), + ( + path!("/code/project"), + json!({ + "src": { + "lib.rs": "", + }, + }), + ), + ], + vec![path!("/code/project")], + { + test_remote!( + "/tmp/a.txt", + "/tmp/a.txt", + "/code/project", + BackgroundPathResolution + ); + } + ) + } + + // See https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + #[should_panic(expected = "Tooltip mismatch")] + async fn issue_34027_gaps(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/project"), + json!({ + "lib": { + "src": { + "main.rs": "" + }, + }, + "src": { + "main.rs": "" + }, + }), + )], + vec![path!("/project")], + { + test!("main.rs", "/project/src/main.rs", "/project"); + test!("main.rs", "/project/lib/src/main.rs", "/project/lib"); + } + ) + } + + // See https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + #[should_panic(expected = "Tooltip mismatch")] + async fn issue_34027_overlap(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/project"), + json!({ + "lib": { + "src": { + "main.rs": "" + }, + }, + "src": { + "main.rs": "" + }, + }), + )], + vec![path!("/project")], + { + // Finds "/project/src/main.rs" + test!( + "src/main.rs", + "/project/lib/src/main.rs", + "/project/lib/src" + ); + } + ) + } + } +} diff --git a/vendor/zed-terminal-view/src/terminal_scrollbar.rs b/vendor/zed-terminal-view/src/terminal_scrollbar.rs new file mode 100644 index 00000000..16dc580e --- /dev/null +++ b/vendor/zed-terminal-view/src/terminal_scrollbar.rs @@ -0,0 +1,87 @@ +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; + +use gpui::{Bounds, Point, point, size}; +use terminal::Terminal; +use ui::{Pixels, ScrollableHandle, px}; + +#[derive(Debug)] +struct ScrollHandleState { + line_height: Pixels, + total_lines: usize, + viewport_lines: usize, + display_offset: usize, +} + +impl ScrollHandleState { + fn new(terminal: &Terminal) -> Self { + Self { + line_height: terminal.last_content().terminal_bounds.line_height, + total_lines: terminal.total_lines(), + viewport_lines: terminal.viewport_lines(), + display_offset: terminal.last_content().display_offset, + } + } +} + +#[derive(Debug, Clone)] +pub struct TerminalScrollHandle { + state: Rc>, + pub future_display_offset: Rc>>, +} + +impl TerminalScrollHandle { + pub fn new(terminal: &Terminal) -> Self { + Self { + state: Rc::new(RefCell::new(ScrollHandleState::new(terminal))), + future_display_offset: Rc::new(Cell::new(None)), + } + } + + pub fn update(&self, terminal: &Terminal) { + *self.state.borrow_mut() = ScrollHandleState::new(terminal); + } +} + +impl ScrollableHandle for TerminalScrollHandle { + fn max_offset(&self) -> Point { + let state = self.state.borrow(); + point( + Pixels::ZERO, + state.total_lines.saturating_sub(state.viewport_lines) as f32 * state.line_height, + ) + } + + fn offset(&self) -> Point { + let state = self.state.borrow(); + let scroll_offset = state + .total_lines + .saturating_sub(state.viewport_lines) + .saturating_sub(state.display_offset); + Point::new(Pixels::ZERO, -(scroll_offset as f32 * state.line_height)) + } + + fn set_offset(&self, point: Point) { + let state = self.state.borrow(); + let offset_delta = (point.y / state.line_height).round() as i32; + + let max_offset = state.total_lines.saturating_sub(state.viewport_lines); + let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32); + + self.future_display_offset + .set(Some(display_offset as usize)); + } + + fn viewport(&self) -> Bounds { + let state = self.state.borrow(); + Bounds::new( + Point::new(px(0.), px(0.)), + size( + Pixels::ZERO, + state.viewport_lines as f32 * state.line_height, + ), + ) + } +} diff --git a/vendor/zed-terminal-view/src/terminal_view.rs b/vendor/zed-terminal-view/src/terminal_view.rs new file mode 100644 index 00000000..aa195627 --- /dev/null +++ b/vendor/zed-terminal-view/src/terminal_view.rs @@ -0,0 +1,3301 @@ +mod persistence; +pub mod terminal_element; +pub mod terminal_panel; +mod terminal_path_like_target; +pub mod terminal_scrollbar; + +use editor::{ + Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager, + ui_scrollbar_settings_from_raw, +}; +use gpui::{ + Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, + FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, + Pixels, Point as GpuiPoint, Render, ScrollWheelEvent, Styled, Subscription, Task, TaskExt, + WeakEntity, actions, anchored, deferred, div, +}; +use menu; +use persistence::TerminalDb; +use project::{Project, ProjectEntryId, search::SearchQuery}; +use schemars::JsonSchema; +use serde::Deserialize; +use settings::{ + SeedQuerySetting, Settings, SettingsStore, TerminalBell, TerminalBlink, WorkingDirectory, +}; +use std::{ + any::Any, + cmp, + ops::Range as StdRange, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; +use task::TaskId; +use terminal::{ + Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, Paste, PasteText, Point, Range, + ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, + Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode, + terminal_settings::{CursorShape, TerminalSettings}, +}; +use terminal_element::TerminalElement; +use terminal_panel::TerminalPanel; +use terminal_path_like_target::{hover_path_like_target, open_path_like_target}; +use terminal_scrollbar::TerminalScrollHandle; +use ui::{ + ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, + prelude::*, + scrollbars::{self, ScrollbarVisibility}, +}; +use util::ResultExt; +use workspace::{ + CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane, + ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, + item::{ + HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, + }, + register_serializable_item, + searchable::{ + Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, + }, +}; +use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist}; + +struct ImeState { + marked_text: String, +} + +fn viewport_line_for_point(point: Point, display_offset: usize) -> Option { + let display_offset = i32::try_from(display_offset).unwrap_or(i32::MAX); + let line = point.line.saturating_add(display_offset); + if line < 0 { + None + } else { + usize::try_from(line).ok() + } +} + +const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); + +/// Event to transmit the scroll from the element to the view +#[derive(Clone, Debug, PartialEq)] +pub struct ScrollTerminal(pub i32); + +/// Sends the specified text directly to the terminal. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = terminal)] +pub struct SendText(String); + +/// Sends a keystroke sequence to the terminal. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = terminal)] +pub struct SendKeystroke(String); + +actions!( + terminal, + [ + /// Reruns the last executed task in the terminal. + RerunTask, + ] +); + +/// Renames the terminal tab. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = terminal)] +pub struct RenameTerminal; + +pub fn init(cx: &mut App) { + terminal_panel::init(cx); + + register_serializable_item::(cx); + + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action(TerminalView::deploy); + }) + .detach(); +} + +pub struct BlockProperties { + pub height: u8, + pub render: Box AnyElement>, +} + +pub struct BlockContext<'a, 'b> { + pub window: &'a mut Window, + pub context: &'b mut App, + pub dimensions: TerminalBounds, +} + +///A terminal view, maintains the PTY's file handles and communicates with the terminal +pub struct TerminalView { + terminal: Entity, + workspace: WeakEntity, + project: WeakEntity, + focus_handle: FocusHandle, + //Currently using iTerm bell, show bell emoji in tab until input is received + has_bell: bool, + context_menu: Option<(Entity, GpuiPoint, Subscription)>, + cursor_shape: CursorShape, + blink_manager: Entity, + mode: TerminalMode, + vertical_alignment: TerminalVerticalAlignment, + // Explicit override for whether workspace-specific context menu actions are shown. + // When `None`, visibility is derived from `mode` (hidden for embedded terminals). + show_workspace_actions: Option, + blinking_terminal_enabled: bool, + needs_serialize: bool, + custom_title: Option, + hover: Option, + hover_tooltip_update: Task<()>, + workspace_id: Option, + show_breadcrumbs: bool, + block_below_cursor: Option>, + scroll_top: Pixels, + scroll_handle: TerminalScrollHandle, + ime_state: Option, + self_handle: WeakEntity, + rename_editor: Option>, + rename_editor_subscription: Option, + _subscriptions: Vec, + _terminal_subscriptions: Vec, +} + +#[derive(Default, Clone)] +pub enum TerminalMode { + #[default] + Standalone, + Embedded { + max_lines_when_unfocused: Option, + }, +} + +/// Controls where a standalone terminal places the fractional row of spare +/// vertical space left after fitting an integral terminal grid into its view. +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalVerticalAlignment { + /// Preserve Zed's default: full and alternate-screen terminals put the + /// spare pixels above the grid so its bottom edge remains fixed. + #[default] + BottomWhenFull, + /// Put spare pixels below the grid, keeping row zero at a stable origin. + Top, +} + +#[derive(Clone)] +pub enum ContentMode { + Scrollable, + Inline { + displayed_lines: usize, + total_lines: usize, + }, +} + +impl ContentMode { + pub fn is_limited(&self) -> bool { + match self { + ContentMode::Scrollable => false, + ContentMode::Inline { + displayed_lines, + total_lines, + } => displayed_lines < total_lines, + } + } + + pub fn is_scrollable(&self) -> bool { + matches!(self, ContentMode::Scrollable) + } +} + +#[derive(Debug)] +#[cfg_attr(test, derive(Clone, Eq, PartialEq))] +struct HoverTarget { + tooltip: String, + hovered_word: HoveredWord, +} + +impl EventEmitter for TerminalView {} +impl EventEmitter for TerminalView {} +impl EventEmitter for TerminalView {} + +impl Focusable for TerminalView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl TerminalView { + ///Create a new Terminal in the current working directory or the user's home directory + pub fn deploy( + workspace: &mut Workspace, + action: &NewCenterTerminal, + window: &mut Window, + cx: &mut Context, + ) { + let local = action.local; + let working_directory = default_working_directory(workspace, cx); + TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| { + if local { + project.create_local_terminal(cx) + } else { + project.create_terminal_shell(working_directory, cx) + } + }) + .detach_and_log_err(cx); + } + + pub fn new( + terminal: Entity, + workspace: WeakEntity, + workspace_id: Option, + project: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let workspace_handle = workspace.clone(); + let terminal_subscriptions = + subscribe_for_terminal_events(&terminal, workspace, window, cx); + + let focus_handle = cx.focus_handle(); + let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| { + terminal_view.focus_in(window, cx); + }); + let focus_out = cx.on_focus_out( + &focus_handle, + window, + |terminal_view, _event, window, cx| { + terminal_view.focus_out(window, cx); + }, + ); + let cursor_shape = TerminalSettings::get_global(cx).cursor_shape; + + let scroll_handle = TerminalScrollHandle::new(terminal.read(cx)); + + let blink_manager = cx.new(|cx| { + BlinkManager::new( + CURSOR_BLINK_INTERVAL, + |cx| { + !matches!( + TerminalSettings::get_global(cx).blinking, + TerminalBlink::Off + ) + }, + cx, + ) + }); + + let subscriptions = vec![ + focus_in, + focus_out, + cx.observe(&blink_manager, |_, _, cx| cx.notify()), + cx.observe_global::(Self::settings_changed), + ]; + + Self { + terminal, + workspace: workspace_handle, + project, + has_bell: false, + focus_handle, + context_menu: None, + cursor_shape, + blink_manager, + blinking_terminal_enabled: false, + hover: None, + hover_tooltip_update: Task::ready(()), + mode: TerminalMode::Standalone, + vertical_alignment: TerminalVerticalAlignment::default(), + show_workspace_actions: None, + workspace_id, + show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, + block_below_cursor: None, + scroll_top: Pixels::ZERO, + scroll_handle, + needs_serialize: false, + custom_title: None, + ime_state: None, + self_handle: cx.entity().downgrade(), + rename_editor: None, + rename_editor_subscription: None, + _subscriptions: subscriptions, + _terminal_subscriptions: terminal_subscriptions, + } + } + + /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines. + pub fn set_embedded_mode( + &mut self, + max_lines_when_unfocused: Option, + cx: &mut Context, + ) { + self.mode = TerminalMode::Embedded { + max_lines_when_unfocused, + }; + cx.notify(); + } + + /// Set the standalone grid's vertical alignment policy. + pub fn set_vertical_alignment( + &mut self, + alignment: TerminalVerticalAlignment, + cx: &mut Context, + ) { + if self.vertical_alignment != alignment { + self.vertical_alignment = alignment; + cx.notify(); + } + } + + /// Explicitly override whether workspace-specific context menu actions (e.g. creating or + /// closing terminal tabs, inline assist) are shown. + /// + /// This lets hosts that aren't workspace panes (such as the agent panel) hide these + /// actions without `terminal_view` needing to know about those hosts. When never called, + /// visibility is derived from the terminal's `mode`. + pub fn set_show_workspace_actions(&mut self, show: bool, cx: &mut Context) { + self.show_workspace_actions = Some(show); + cx.notify(); + } + + fn shows_workspace_actions(&self) -> bool { + self.show_workspace_actions + .unwrap_or_else(|| !matches!(self.mode, TerminalMode::Embedded { .. })) + } + + const MAX_EMBEDDED_LINES: usize = 1_000; + + /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines + /// + /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES` + pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode { + match &self.mode { + TerminalMode::Standalone => ContentMode::Scrollable, + TerminalMode::Embedded { + max_lines_when_unfocused, + } => { + let terminal = self.terminal.read(cx); + let total_lines = terminal.total_lines(); + + if total_lines > Self::MAX_EMBEDDED_LINES { + ContentMode::Scrollable + } else { + let mut displayed_lines = terminal.used_lines().min(total_lines); + + if !self.focus_handle.is_focused(window) + && let Some(max_lines) = max_lines_when_unfocused + { + displayed_lines = displayed_lines.min(*max_lines) + } + + ContentMode::Inline { + displayed_lines, + total_lines, + } + } + } + } + } + + /// Sets the marked (pre-edit) text from the IME. + pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context) { + if text.is_empty() { + return self.clear_marked_text(cx); + } + self.ime_state = Some(ImeState { marked_text: text }); + cx.notify(); + } + + /// Gets the current marked range (UTF-16). + pub(crate) fn marked_text_range(&self) -> Option> { + self.ime_state + .as_ref() + .map(|state| 0..state.marked_text.encode_utf16().count()) + } + + /// Clears the marked (pre-edit) text state. + pub(crate) fn clear_marked_text(&mut self, cx: &mut Context) { + if self.ime_state.is_some() { + self.ime_state = None; + cx.notify(); + } + } + + /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range. + pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context) { + if !text.is_empty() { + self.terminal.update(cx, |term, _| { + term.input(text.to_string().into_bytes()); + }); + } + } + + pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds { + self.terminal.read(cx).last_content().terminal_bounds + } + + pub fn entity(&self) -> &Entity { + &self.terminal + } + + pub fn has_bell(&self) -> bool { + self.has_bell + } + + pub fn custom_title(&self) -> Option<&str> { + self.custom_title.as_deref() + } + + pub fn set_custom_title(&mut self, label: Option, cx: &mut Context) { + let label = label.filter(|l| !l.trim().is_empty()); + if self.custom_title != label { + self.custom_title = label; + self.needs_serialize = true; + cx.emit(ItemEvent::UpdateTab); + cx.notify(); + } + } + + pub fn is_renaming(&self) -> bool { + self.rename_editor.is_some() + } + + pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool { + self.rename_editor + .as_ref() + .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)) + } + + fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context) { + let Some(editor) = self.rename_editor.take() else { + return; + }; + self.rename_editor_subscription = None; + if save { + let new_label = editor.read(cx).text(cx).trim().to_string(); + let label = if new_label.is_empty() { + None + } else { + // Only set custom_title if the text differs from the terminal's dynamic title. + // This prevents subtle layout changes when clicking away without making changes. + let terminal_title = self.terminal.read(cx).title(true); + if new_label == terminal_title { + None + } else { + Some(new_label) + } + }; + self.set_custom_title(label, cx); + } + cx.notify(); + self.focus_handle.focus(window, cx); + } + + pub fn rename_terminal( + &mut self, + _: &RenameTerminal, + window: &mut Window, + cx: &mut Context, + ) { + if self.terminal.read(cx).task().is_some() { + return; + } + + let current_label = self + .custom_title + .clone() + .unwrap_or_else(|| self.terminal.read(cx).title(true)); + + let rename_editor = cx.new(|cx| Editor::single_line(window, cx)); + let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, { + let rename_editor = rename_editor.clone(); + move |_this, _, event, window, cx| { + if let editor::EditorEvent::Blurred = event { + // Defer to let focus settle (avoids canceling during double-click). + let rename_editor = rename_editor.clone(); + cx.defer_in(window, move |this, window, cx| { + let still_current = this + .rename_editor + .as_ref() + .is_some_and(|current| current == &rename_editor); + if still_current && !rename_editor.focus_handle(cx).is_focused(window) { + this.finish_renaming(false, window, cx); + } + }); + } + } + }); + + self.rename_editor = Some(rename_editor.clone()); + self.rename_editor_subscription = Some(rename_editor_subscription); + + rename_editor.update(cx, |editor, cx| { + editor.set_text(current_label, window, cx); + editor.select_all(&SelectAll, window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + cx.notify(); + } + + pub fn clear_bell(&mut self, cx: &mut Context) { + self.has_bell = false; + cx.emit(Event::Wakeup); + } + + pub fn deploy_context_menu( + &mut self, + position: GpuiPoint, + has_selection: bool, + window: &mut Window, + cx: &mut Context, + ) { + let assistant_enabled = self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled()); + let context_menu = ContextMenu::build(window, cx, |menu, _, _| { + menu.context(self.focus_handle.clone()) + .when(self.shows_workspace_actions(), |menu| { + menu.action("New Terminal", Box::new(NewTerminal::default())) + .action( + "New Center Terminal", + Box::new(NewCenterTerminal::default()), + ) + .separator() + }) + .action("Copy", Box::new(Copy)) + .when( + !matches!(self.mode, TerminalMode::Embedded { .. }), + |menu| { + menu.action("Paste", Box::new(Paste)) + .action("Paste Text", Box::new(PasteText)) + }, + ) + .action("Select All", Box::new(SelectAll)) + .when( + !matches!(self.mode, TerminalMode::Embedded { .. }), + |menu| menu.action("Clear", Box::new(Clear)), + ) + .when( + assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), + |menu| { + menu.separator() + .action("Inline Assist", Box::new(InlineAssist::default())) + .when(has_selection && self.shows_workspace_actions(), |menu| { + menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) + }, + ) + .when(self.shows_workspace_actions(), |menu| { + menu.separator().action( + "Close Terminal Tab", + Box::new(CloseActiveItem { + save_intent: None, + close_pinned: true, + }), + ) + }) + }); + + window.focus(&context_menu.focus_handle(cx), cx); + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.context_menu.take(); + cx.notify(); + }, + ); + + self.context_menu = Some((context_menu, position, subscription)); + } + + fn settings_changed(&mut self, cx: &mut Context) { + let settings = TerminalSettings::get_global(cx); + let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs; + self.show_breadcrumbs = settings.toolbar.breadcrumbs; + + let should_blink = match settings.blinking { + TerminalBlink::Off => false, + TerminalBlink::On => true, + TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, + }; + let new_cursor_shape = settings.cursor_shape; + let old_cursor_shape = self.cursor_shape; + if old_cursor_shape != new_cursor_shape { + self.cursor_shape = new_cursor_shape; + self.terminal.update(cx, |term, _| { + term.set_cursor_shape(self.cursor_shape); + }); + } + + self.blink_manager.update( + cx, + if should_blink { + BlinkManager::enable + } else { + BlinkManager::disable + }, + ); + + if breadcrumb_visibility_changed { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + cx.notify(); + } + + fn show_character_palette( + &mut self, + _: &ShowCharacterPalette, + window: &mut Window, + cx: &mut Context, + ) { + if self + .terminal + .read(cx) + .last_content + .mode + .contains(Modes::ALT_SCREEN) + { + self.terminal.update(cx, |term, cx| { + term.try_keystroke( + &Keystroke::parse("ctrl-cmd-space").unwrap(), + TerminalSettings::get_global(cx).option_as_meta, + ) + }); + } else { + window.show_character_palette(); + } + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |term, _| term.select_all()); + cx.notify(); + } + + fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context) { + let task = self + .terminal + .read(cx) + .task() + .map(|task| terminal_rerun_override(&task.spawned_task.id)) + .unwrap_or_default(); + window.dispatch_action(Box::new(task), cx); + } + + fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { + self.scroll_top = px(0.); + self.terminal.update(cx, |term, _| term.clear()); + cx.notify(); + } + + fn max_scroll_top(&self, cx: &App) -> Pixels { + let terminal = self.terminal.read(cx); + + let Some(block) = self.block_below_cursor.as_ref() else { + return Pixels::ZERO; + }; + + let line_height = terminal.last_content().terminal_bounds.line_height; + let viewport_lines = terminal.viewport_lines(); + let cursor_line = viewport_line_for_point( + terminal.last_content.cursor.point, + terminal.last_content.display_offset, + ) + .unwrap_or_default(); + let max_scroll_top_in_lines = + (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor_line + 1)); + + max_scroll_top_in_lines as f32 * line_height + } + + fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context) { + let terminal_content = self.terminal.read(cx).last_content(); + + if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { + let line_height = terminal_content.terminal_bounds.line_height; + let y_delta = event.delta.pixel_delta(line_height).y; + if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO { + self.scroll_top = cmp::max( + Pixels::ZERO, + cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)), + ); + cx.notify(); + return; + } + } + self.terminal.update(cx, |term, cx| { + term.scroll_wheel( + event, + TerminalSettings::get_global(cx).scroll_multiplier.max(0.01), + ) + }); + } + + fn is_alt_screen(&self, cx: &App) -> bool { + self.terminal + .read(cx) + .last_content + .mode + .contains(Modes::ALT_SCREEN) + } + + fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + let terminal_content = self.terminal.read(cx).last_content(); + if self.block_below_cursor.is_some() + && terminal_content.display_offset == 0 + && self.scroll_top > Pixels::ZERO + { + let line_height = terminal_content.terminal_bounds.line_height; + self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO); + return; + } + + self.terminal.update(cx, |term, _| term.scroll_line_up()); + cx.notify(); + } + + fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + let terminal_content = self.terminal.read(cx).last_content(); + if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { + let max_scroll_top = self.max_scroll_top(cx); + if self.scroll_top < max_scroll_top { + let line_height = terminal_content.terminal_bounds.line_height; + self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top); + } + return; + } + + self.terminal.update(cx, |term, _| term.scroll_line_down()); + cx.notify(); + } + + fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + if self.scroll_top == Pixels::ZERO { + self.terminal.update(cx, |term, _| term.scroll_page_up()); + } else { + let line_height = self + .terminal + .read(cx) + .last_content + .terminal_bounds + .line_height(); + let visible_block_lines = (self.scroll_top / line_height) as usize; + let viewport_lines = self.terminal.read(cx).viewport_lines(); + let visible_content_lines = viewport_lines - visible_block_lines; + + if visible_block_lines >= viewport_lines { + self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height; + } else { + self.scroll_top = px(0.); + self.terminal + .update(cx, |term, _| term.scroll_up_by(visible_content_lines)); + } + } + cx.notify(); + } + + fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + self.terminal.update(cx, |term, _| term.scroll_page_down()); + let terminal = self.terminal.read(cx); + if terminal.last_content().display_offset < terminal.viewport_lines() { + self.scroll_top = self.max_scroll_top(cx); + } + cx.notify(); + } + + fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + self.terminal.update(cx, |term, _| term.scroll_to_top()); + cx.notify(); + } + + fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context) { + if self.is_alt_screen(cx) { + cx.propagate(); + return; + } + + self.terminal.update(cx, |term, _| term.scroll_to_bottom()); + if self.block_below_cursor.is_some() { + self.scroll_top = self.max_scroll_top(cx); + } + cx.notify(); + } + + fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |term, _| term.toggle_vi_mode()); + cx.notify(); + } + + pub fn should_show_cursor(&self, focused: bool, cx: &mut Context) -> bool { + // Hide cursor when in embedded mode and not focused (read-only output like Agent panel) + if let TerminalMode::Embedded { .. } = &self.mode { + if !focused { + return false; + } + } + + // For Standalone mode: always show cursor when not focused or in special modes + if !focused + || self + .terminal + .read(cx) + .last_content + .mode + .contains(Modes::ALT_SCREEN) + { + return true; + } + + // When focused, check blinking settings and blink manager state + match TerminalSettings::get_global(cx).blinking { + TerminalBlink::Off => true, + TerminalBlink::TerminalControlled => { + !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible() + } + TerminalBlink::On => self.blink_manager.read(cx).visible(), + } + } + + pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context) { + self.blink_manager.update(cx, BlinkManager::pause_blinking); + } + + pub fn terminal(&self) -> &Entity { + &self.terminal + } + + pub fn set_block_below_cursor( + &mut self, + block: BlockProperties, + window: &mut Window, + cx: &mut Context, + ) { + self.block_below_cursor = Some(Rc::new(block)); + self.scroll_to_bottom(&ScrollToBottom, window, cx); + cx.notify(); + } + + pub fn clear_block_below_cursor(&mut self, cx: &mut Context) { + self.block_below_cursor = None; + self.scroll_top = Pixels::ZERO; + cx.notify(); + } + + ///Attempt to paste the clipboard into the terminal + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |term, _| term.copy(None)); + cx.notify(); + } + + /// Specific handler for the [`editor::actions::Copy`] action in order for + /// the `Edit > Copy` menu item to not be disabled, as the app expects a + /// handler for this action in order to enable/disable the menu item. + fn editor_copy( + &mut self, + _: &editor::actions::Copy, + window: &mut Window, + cx: &mut Context, + ) { + self.copy(&Copy, window, cx); + } + + ///Attempt to paste the clipboard into the terminal + fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + + match clipboard.entries().first() { + Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => { + self.forward_ctrl_v(cx); + } + Some(ClipboardEntry::ExternalPaths(paths)) => { + self.add_paths_to_terminal(paths.paths(), window, cx); + } + _ => { + if let Some(text) = clipboard.text() { + self.terminal + .update(cx, |terminal, _cx| terminal.paste(&text)); + } + } + } + } + + /// Specific handler for the [`editor::actions::Paste`] action in order for + /// the `Edit > Paste` menu item to not be disabled, as the app expects a + /// handler for this action in order to enable/disable the menu item. + fn editor_paste( + &mut self, + _: &editor::actions::Paste, + window: &mut Window, + cx: &mut Context, + ) { + self.paste(&Paste, window, cx); + } + + ///Attempt to paste the clipboard text into the terminal + fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context) { + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + + if let Some(text) = clipboard.text() { + self.terminal + .update(cx, |terminal, _cx| terminal.paste(&text)); + } + } + + /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly + /// and attach images using their native workflows. + fn forward_ctrl_v(&self, cx: &mut Context) { + self.terminal.update(cx, |term, _| { + term.input(vec![0x16]); + }); + } + + pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + let mut text = paths + .iter() + .filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?))) + .collect::(); + text.push(' '); + window.focus(&self.focus_handle(cx), cx); + self.terminal.update(cx, |terminal, _| { + terminal.paste(&text); + }); + } + + fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { + self.clear_bell(cx); + self.blink_manager.update(cx, BlinkManager::pause_blinking); + self.terminal.update(cx, |term, _| { + term.input(text.0.to_string().into_bytes()); + }); + } + + fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context) { + if let Some(keystroke) = Keystroke::parse(&text.0).log_err() { + self.clear_bell(cx); + self.blink_manager.update(cx, BlinkManager::pause_blinking); + self.process_keystroke(&keystroke, cx); + } + } + + fn dispatch_context(&self, cx: &App) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("Terminal"); + + if self.terminal.read(cx).vi_mode_enabled() { + dispatch_context.add("vi_mode"); + } + + let mode = self.terminal.read(cx).last_content.mode; + dispatch_context.set( + "screen", + if mode.contains(Modes::ALT_SCREEN) { + "alt" + } else { + "normal" + }, + ); + + if mode.contains(Modes::APP_CURSOR) { + dispatch_context.add("DECCKM"); + } + if mode.contains(Modes::APP_KEYPAD) { + dispatch_context.add("DECPAM"); + } else { + dispatch_context.add("DECPNM"); + } + if mode.contains(Modes::SHOW_CURSOR) { + dispatch_context.add("DECTCEM"); + } + if mode.contains(Modes::LINE_WRAP) { + dispatch_context.add("DECAWM"); + } + if mode.contains(Modes::ORIGIN) { + dispatch_context.add("DECOM"); + } + if mode.contains(Modes::INSERT) { + dispatch_context.add("IRM"); + } + //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html + if mode.contains(Modes::LINE_FEED_NEW_LINE) { + dispatch_context.add("LNM"); + } + if mode.contains(Modes::FOCUS_IN_OUT) { + dispatch_context.add("report_focus"); + } + if mode.contains(Modes::ALTERNATE_SCROLL) { + dispatch_context.add("alternate_scroll"); + } + if mode.contains(Modes::BRACKETED_PASTE) { + dispatch_context.add("bracketed_paste"); + } + if mode.intersects(Modes::MOUSE_MODE) { + dispatch_context.add("any_mouse_reporting"); + } + { + let mouse_reporting = if mode.contains(Modes::MOUSE_REPORT_CLICK) { + "click" + } else if mode.contains(Modes::MOUSE_DRAG) { + "drag" + } else if mode.contains(Modes::MOUSE_MOTION) { + "motion" + } else { + "off" + }; + dispatch_context.set("mouse_reporting", mouse_reporting); + } + { + let format = if mode.contains(Modes::SGR_MOUSE) { + "sgr" + } else if mode.contains(Modes::UTF8_MOUSE) { + "utf8" + } else { + "normal" + }; + dispatch_context.set("mouse_format", format); + }; + + if self.terminal.read(cx).last_content.selection.is_some() { + dispatch_context.add("selection"); + } + + dispatch_context + } + + fn set_terminal( + &mut self, + terminal: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self._terminal_subscriptions = + subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx); + self.terminal = terminal; + } + + fn rerun_button(task: &TaskState) -> Option { + if !task.spawned_task.show_rerun { + return None; + } + + let task_id = task.spawned_task.id.clone(); + Some( + IconButton::new("rerun-icon", IconName::Rerun) + .icon_size(IconSize::Small) + .size(ButtonSize::Compact) + .icon_color(Color::Default) + .shape(ui::IconButtonShape::Square) + .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx); + }), + ) + } +} + +fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun { + zed_actions::Rerun { + task_id: Some(task.0.clone()), + allow_concurrent_runs: Some(true), + use_new_terminal: Some(false), + reevaluate_context: false, + } +} + +fn subscribe_for_terminal_events( + terminal: &Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, +) -> Vec { + let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify()); + let mut previous_cwd = None; + let terminal_events_subscription = cx.subscribe_in( + terminal, + window, + move |terminal_view, terminal, event, window, cx| { + let current_cwd = terminal.read(cx).working_directory(); + if current_cwd != previous_cwd { + previous_cwd = current_cwd; + terminal_view.needs_serialize = true; + } + + match event { + Event::Wakeup => { + cx.notify(); + window.invalidate_character_coordinates(); + cx.emit(Event::Wakeup); + cx.emit(ItemEvent::UpdateTab); + cx.emit(SearchEvent::MatchesInvalidated); + } + + Event::Bell => { + terminal_view.has_bell = true; + if let TerminalBell::System = TerminalSettings::get_global(cx).bell { + window.play_system_bell(); + } + cx.emit(Event::Wakeup); + } + + Event::BlinkChanged(blinking) => { + terminal_view.blinking_terminal_enabled = *blinking; + + // If in terminal-controlled mode and focused, update blink manager + if matches!( + TerminalSettings::get_global(cx).blinking, + TerminalBlink::TerminalControlled + ) && terminal_view.focus_handle.is_focused(window) + { + terminal_view.blink_manager.update(cx, |manager, cx| { + if *blinking { + manager.enable(cx); + } else { + manager.disable(cx); + } + }); + } + } + + Event::TitleChanged => { + cx.emit(ItemEvent::UpdateTab); + } + + Event::NewNavigationTarget(maybe_navigation_target) => { + match maybe_navigation_target + .as_ref() + .zip(terminal.read(cx).last_content.last_hovered_word.as_ref()) + { + Some((MaybeNavigationTarget::Url(url), hovered_word)) => { + if Some(hovered_word) + != terminal_view + .hover + .as_ref() + .map(|hover| &hover.hovered_word) + { + terminal_view.hover = Some(HoverTarget { + tooltip: url.clone(), + hovered_word: hovered_word.clone(), + }); + terminal_view.hover_tooltip_update = Task::ready(()); + cx.notify(); + } + } + Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => { + if Some(hovered_word) + != terminal_view + .hover + .as_ref() + .map(|hover| &hover.hovered_word) + { + terminal_view.hover = None; + terminal_view.hover_tooltip_update = hover_path_like_target( + &workspace, + hovered_word.clone(), + path_like_target, + cx, + ); + cx.notify(); + } + } + None => { + terminal_view.hover = None; + terminal_view.hover_tooltip_update = Task::ready(()); + cx.notify(); + } + } + } + + Event::Open(maybe_navigation_target) => match maybe_navigation_target { + MaybeNavigationTarget::Url(url) => cx.open_url(url), + MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target( + &workspace, + terminal_view, + path_like_target, + window, + cx, + ), + }, + Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs), + Event::CloseTerminal => cx.emit(ItemEvent::CloseItem), + Event::SelectionsChanged => { + window.invalidate_character_coordinates(); + cx.emit(SearchEvent::ActiveMatchChanged) + } + } + }, + ); + vec![terminal_subscription, terminal_events_subscription] +} + +fn regex_search_for_query(query: &SearchQuery) -> Option { + let str = query.as_str(); + if query.is_regex() { + if str == "." { + return None; + } + Search::new(str) + } else { + Search::new(®ex::escape(str)) + } +} + +#[derive(Default)] +struct TerminalScrollbarSettingsWrapper; + +impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper { + fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar { + TerminalSettings::get_global(cx) + .scrollbar + .show + .map(ui_scrollbar_settings_from_raw) + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) + } +} + +impl TerminalView { + /// Attempts to process a keystroke in the terminal. Returns true if handled. + /// + /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k) + /// updates the cursor locally without sending data to the shell, so there's no + /// shell output to automatically trigger a re-render. + fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { + let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { + ( + term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta), + term.vi_mode_enabled(), + ) + }); + + if handled && vi_mode_enabled { + cx.notify(); + } + + handled + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + self.clear_bell(cx); + self.pause_cursor_blinking(window, cx); + + if self.process_keystroke(&event.keystroke, cx) { + cx.stop_propagation(); + } + } + + fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { + self.terminal.update(cx, |terminal, _| { + terminal.set_cursor_shape(self.cursor_shape); + terminal.focus_in(); + }); + + let should_blink = match TerminalSettings::get_global(cx).blinking { + TerminalBlink::Off => false, + TerminalBlink::On => true, + TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, + }; + + if should_blink { + self.blink_manager.update(cx, BlinkManager::enable); + } + + window.invalidate_character_coordinates(); + cx.notify(); + } + + fn focus_out(&mut self, _window: &mut Window, cx: &mut Context) { + self.blink_manager.update(cx, BlinkManager::disable); + self.terminal.update(cx, |terminal, _| { + terminal.focus_out(); + terminal.set_cursor_shape(CursorShape::Hollow); + }); + cx.notify(); + } +} + +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // TODO: this should be moved out of render + self.scroll_handle.update(self.terminal.read(cx)); + + if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() { + self.terminal.update(cx, |term, _| { + let delta = new_display_offset as i32 - term.last_content.display_offset as i32; + match delta.cmp(&0) { + cmp::Ordering::Greater => term.scroll_up_by(delta as usize), + cmp::Ordering::Less => term.scroll_down_by(-delta as usize), + cmp::Ordering::Equal => {} + } + }); + } + + let terminal_handle = self.terminal.clone(); + let terminal_view_handle = cx.entity(); + + let focused = self.focus_handle.is_focused(window); + + div() + .id("terminal-view") + .size_full() + .relative() + .track_focus(&self.focus_handle(cx)) + .key_context(self.dispatch_context(cx)) + .on_action(cx.listener(TerminalView::send_text)) + .on_action(cx.listener(TerminalView::send_keystroke)) + .on_action(cx.listener(TerminalView::copy)) + .on_action(cx.listener(TerminalView::editor_copy)) + .on_action(cx.listener(TerminalView::paste)) + .on_action(cx.listener(TerminalView::editor_paste)) + .on_action(cx.listener(TerminalView::paste_text)) + .on_action(cx.listener(TerminalView::clear)) + .on_action(cx.listener(TerminalView::scroll_line_up)) + .on_action(cx.listener(TerminalView::scroll_line_down)) + .on_action(cx.listener(TerminalView::scroll_page_up)) + .on_action(cx.listener(TerminalView::scroll_page_down)) + .on_action(cx.listener(TerminalView::scroll_to_top)) + .on_action(cx.listener(TerminalView::scroll_to_bottom)) + .on_action(cx.listener(TerminalView::toggle_vi_mode)) + .on_action(cx.listener(TerminalView::show_character_palette)) + .on_action(cx.listener(TerminalView::select_all)) + .on_action(cx.listener(TerminalView::rerun_task)) + .on_action(cx.listener(TerminalView::rename_terminal)) + .on_key_down(cx.listener(Self::key_down)) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) { + let had_selection = this.terminal.read(cx).last_content.selection.is_some(); + if !had_selection { + this.terminal.update(cx, |terminal, _| { + terminal.select_word_at_event_position(event); + }); + } + let has_selection = !had_selection + || this + .terminal + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()); + this.deploy_context_menu(event.position, has_selection, window, cx); + cx.notify(); + } + }), + ) + .child( + // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu + div() + .id("terminal-view-container") + .size_full() + .bg(cx.theme().colors().editor_background) + .child(TerminalElement::new( + terminal_handle, + terminal_view_handle, + self.workspace.clone(), + self.focus_handle.clone(), + focused, + self.should_show_cursor(focused, cx), + self.block_below_cursor.clone(), + self.mode.clone(), + )) + .when(self.content_mode(window, cx).is_scrollable(), |div| { + let colors = cx.theme().colors(); + div.custom_scrollbars( + Scrollbars::for_settings::() + .show_along(ScrollAxes::Vertical) + .with_stable_track_along( + ScrollAxes::Vertical, + colors.editor_background, + ) + .tracked_scroll_handle(&self.scroll_handle), + window, + cx, + ) + }), + ) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Anchor::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + } +} + +impl Item for TerminalView { + type Event = ItemEvent; + + fn tab_tooltip_content(&self, cx: &App) -> Option { + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let terminal = self.terminal().read(cx); + let title = terminal.title(false); + let pid = terminal.pid_getter()?.fallback_pid(); + + move |_, _| { + v_flex() + .gap_1() + .child(Label::new(title.clone())) + .child(h_flex().flex_grow_1().child(Divider::horizontal())) + .child( + Label::new(format!("Process ID (PID): {}", pid)) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + let terminal = self.terminal().read(cx); + let title = self + .custom_title + .as_ref() + .filter(|title| !title.trim().is_empty()) + .cloned() + .unwrap_or_else(|| terminal.title(true)); + + let (icon, icon_color, rerun_button) = match terminal.task() { + Some(terminal_task) => match &terminal_task.status { + TaskStatus::Running => ( + IconName::PlayFilled, + Color::Disabled, + TerminalView::rerun_button(terminal_task), + ), + TaskStatus::Unknown => ( + IconName::Warning, + Color::Warning, + TerminalView::rerun_button(terminal_task), + ), + TaskStatus::Completed { success } => { + let rerun_button = TerminalView::rerun_button(terminal_task); + + if *success { + (IconName::Check, Color::Success, rerun_button) + } else { + (IconName::XCircle, Color::Error, rerun_button) + } + } + }, + None => (IconName::Terminal, Color::Muted, None), + }; + + let self_handle = self.self_handle.clone(); + h_flex() + .gap_1() + .group("term-tab-icon") + .when(!params.selected, |this| { + this.track_focus(&self.focus_handle) + }) + .on_action(move |action: &RenameTerminal, window, cx| { + self_handle + .update(cx, |this, cx| this.rename_terminal(action, window, cx)) + .ok(); + }) + .child( + h_flex() + .group("term-tab-icon") + .child( + div() + .when(rerun_button.is_some(), |this| { + this.hover(|style| style.invisible().w_0()) + }) + .child(Icon::new(icon).color(icon_color)), + ) + .when_some(rerun_button, |this, rerun_button| { + this.child( + div() + .absolute() + .visible_on_hover("term-tab-icon") + .child(rerun_button), + ) + }), + ) + .child( + div() + .relative() + .child( + Label::new(title) + .single_line() + .color(params.text_color()) + .when(self.is_renaming(), |this| this.alpha(0.)), + ) + .when_some(self.rename_editor.clone(), |this, editor| { + let self_handle = self.self_handle.clone(); + let self_handle_cancel = self.self_handle.clone(); + this.child( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .child(editor) + .on_action(move |_: &menu::Confirm, window, cx| { + self_handle + .update(cx, |this, cx| { + this.finish_renaming(true, window, cx) + }) + .ok(); + }) + .on_action(move |_: &menu::Cancel, window, cx| { + self_handle_cancel + .update(cx, |this, cx| { + this.finish_renaming(false, window, cx) + }) + .ok(); + }), + ) + }), + ) + .into_any() + } + + fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString { + if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) { + return custom_title.clone().into(); + } + let terminal = self.terminal().read(cx); + terminal.title(detail == 0).into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn handle_drop( + &self, + active_pane: &Pane, + dropped: &dyn Any, + window: &mut Window, + cx: &mut App, + ) -> bool { + let Some(project) = self.project.upgrade() else { + return false; + }; + + if let Some(paths) = dropped.downcast_ref::() { + let is_local = project.read(cx).is_local(); + if is_local { + self.add_paths_to_terminal(paths.paths(), window, cx); + return true; + } + + return false; + } else if let Some(tab) = dropped.downcast_ref::() { + let Some(self_handle) = self.self_handle.upgrade() else { + return false; + }; + + let Some(workspace) = self.workspace.upgrade() else { + return false; + }; + + let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else { + return false; + }; + + let item = if tab.pane == this_pane { + active_pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + + let Some(item) = item else { + return false; + }; + + if item.downcast::().is_some() { + let Some(split_direction) = active_pane.drag_split_direction() else { + return false; + }; + + let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { + return false; + }; + + if !terminal_panel.read(cx).center.panes().contains(&&this_pane) { + return false; + } + + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + let is_zoomed = { + let terminal_panel = terminal_panel.read(cx); + if terminal_panel.active_pane == this_pane { + active_pane.is_zoomed() + } else { + terminal_panel.active_pane.read(cx).is_zoomed() + } + }; + + let workspace = workspace.downgrade(); + let terminal_panel = terminal_panel.downgrade(); + // Defer the split operation to avoid re-entrancy panic. + // The pane may be the one currently being updated, so we cannot + // call mark_positions (via split) synchronously. + window + .spawn(cx, async move |cx| { + cx.update(|window, cx| { + let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = terminal_panel::new_terminal_pane( + workspace, project, is_zoomed, window, cx, + ); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel.center.split( + &this_pane, + &new_pane, + split_direction, + cx, + ); + anyhow::Ok(new_pane) + }) else { + return; + }; + + let Some(new_pane) = new_pane.log_err() else { + return; + }; + + workspace::move_item( + &source, + &new_pane, + item_id_to_move, + new_pane.read(cx).active_item_index(), + true, + window, + cx, + ); + }) + .ok(); + }) + .detach(); + + return true; + } else { + if let Some(project_path) = item.project_path(cx) + && let Some(path) = project.read(cx).absolute_path(&project_path, cx) + { + self.add_paths_to_terminal(&[path], window, cx); + return true; + } + } + + return false; + } else if let Some(selection) = dropped.downcast_ref::() { + let project = project.read(cx); + let paths = selection + .items() + .map(|selected_entry| selected_entry.entry_id) + .filter_map(|entry_id| project.path_for_entry(entry_id, cx)) + .filter_map(|project_path| project.absolute_path(&project_path, cx)) + .collect::>(); + + if !paths.is_empty() { + self.add_paths_to_terminal(&paths, window, cx); + } + + return true; + } else if let Some(&entry_id) = dropped.downcast_ref::() { + let project = project.read(cx); + if let Some(path) = project + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.absolute_path(&project_path, cx)) + { + self.add_paths_to_terminal(&[path], window, cx); + } + + return true; + } + + false + } + + fn tab_extra_context_menu_actions( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> Vec<(SharedString, Box)> { + let terminal = self.terminal.read(cx); + if terminal.task().is_none() { + vec![("Rename".into(), Box::new(RenameTerminal))] + } else { + Vec::new() + } + } + + fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Singleton + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let Ok(terminal) = self.project.update(cx, |project, cx| { + let cwd = project + .active_project_directory(cx) + .map(|it| it.to_path_buf()); + project.clone_terminal(self.terminal(), cx, cwd) + }) else { + return Task::ready(None); + }; + cx.spawn_in(window, async move |this, cx| { + let terminal = terminal.await.log_err()?; + this.update_in(cx, |this, window, cx| { + cx.new(|cx| { + TerminalView::new( + terminal, + this.workspace.clone(), + workspace_id, + this.project.clone(), + window, + cx, + ) + }) + }) + .ok() + }) + } + + fn is_dirty(&self, cx: &App) -> bool { + match self.terminal.read(cx).task() { + Some(task) => task.status == TaskStatus::Running, + None => self.has_bell(), + } + } + + fn has_conflict(&self, _cx: &App) -> bool { + false + } + + fn can_save_as(&self, _cx: &App) -> bool { + false + } + + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { + Some(Box::new(handle.clone())) + } + + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } + } + + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + Some(( + vec![HighlightedText { + text: self.terminal().read(cx).breadcrumb_text.clone().into(), + highlights: vec![], + }], + None, + )) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + _: &mut Window, + cx: &mut Context, + ) { + if self.terminal().read(cx).task().is_none() { + if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) { + log::debug!( + "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}", + ); + let db = TerminalDb::global(cx); + let entity_id = cx.entity_id().as_u64(); + cx.background_spawn(async move { + db.update_workspace_id(new_id, old_id, entity_id).await + }) + .detach(); + } + self.workspace_id = workspace.database_id(); + } + } + + fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) { + f(*event) + } +} + +impl SerializableItem for TerminalView { + fn serialized_item_kind() -> &'static str { + "Terminal" + } + + fn cleanup( + workspace_id: WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + let db = TerminalDb::global(cx); + delete_unloaded_items(alive_items, workspace_id, "terminals", &db, cx) + } + + fn serialize( + &mut self, + _workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + _: &mut Window, + cx: &mut Context, + ) -> Option>> { + let terminal = self.terminal().read(cx); + if terminal.task().is_some() { + return None; + } + + if !self.needs_serialize { + return None; + } + + let workspace_id = self.workspace_id?; + let cwd = terminal.working_directory(); + let custom_title = self.custom_title.clone(); + self.needs_serialize = false; + + let db = TerminalDb::global(cx); + Some(cx.background_spawn(async move { + if let Some(cwd) = cwd { + db.save_working_directory(item_id, workspace_id, cwd) + .await?; + } + db.save_custom_title(item_id, workspace_id, custom_title) + .await?; + Ok(()) + })) + } + + fn should_serialize(&self, _: &Self::Event) -> bool { + self.needs_serialize + } + + fn deserialize( + project: Entity, + workspace: WeakEntity, + workspace_id: WorkspaceId, + item_id: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let (cwd, custom_title) = cx + .update(|_window, cx| { + let db = TerminalDb::global(cx); + let from_db = db + .get_working_directory(item_id, workspace_id) + .log_err() + .flatten(); + let cwd = if from_db + .as_ref() + .is_some_and(|from_db| !from_db.as_os_str().is_empty()) + { + from_db + } else { + workspace + .upgrade() + .and_then(|workspace| default_working_directory(workspace.read(cx), cx)) + }; + let custom_title = db + .get_custom_title(item_id, workspace_id) + .log_err() + .flatten() + .filter(|title| !title.trim().is_empty()); + (cwd, custom_title) + }) + .ok() + .unwrap_or((None, None)); + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx)) + .await?; + cx.update(|window, cx| { + cx.new(|cx| { + let mut view = TerminalView::new( + terminal, + workspace, + Some(workspace_id), + project.downgrade(), + window, + cx, + ); + if custom_title.is_some() { + view.custom_title = custom_title; + } + view + }) + }) + }) + } +} + +impl SearchableItem for TerminalView { + type Match = Range; + + fn supported_options(&self) -> SearchOptions { + SearchOptions { + case: false, + word: false, + regex: true, + replacement: false, + selection: false, + select_all: false, + find_in_results: false, + } + } + + /// Clear stored matches + fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context) { + self.terminal().update(cx, |term, _| term.matches.clear()) + } + + /// Store matches returned from find_matches somewhere for rendering + fn update_matches( + &mut self, + matches: &[Self::Match], + _active_match_index: Option, + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) { + self.terminal() + .update(cx, |term, _| term.matches = matches.to_vec()) + } + + /// Returns the selection content to pre-load into this search + fn query_suggestion( + &mut self, + _seed_query_override: Option, + _window: &mut Window, + cx: &mut Context, + ) -> String { + self.terminal() + .read(cx) + .last_content + .selection_text + .clone() + .unwrap_or_default() + } + + /// Focus match at given index into the Vec of matches + fn activate_match( + &mut self, + index: usize, + _: &[Self::Match], + _token: SearchToken, + _window: &mut Window, + cx: &mut Context, + ) { + self.terminal() + .update(cx, |term, _| term.activate_match(index)); + cx.notify(); + } + + /// Add selections for all matches given. + fn select_matches( + &mut self, + matches: &[Self::Match], + _token: SearchToken, + _: &mut Window, + cx: &mut Context, + ) { + self.terminal() + .update(cx, |term, _| term.select_matches(matches)); + cx.notify(); + } + + /// Get all of the matches for this query, should be done on the background + fn find_matches( + &mut self, + query: Arc, + _: &mut Window, + cx: &mut Context, + ) -> Task> { + if let Some(s) = regex_search_for_query(&query) { + self.terminal() + .update(cx, |term, cx| term.find_matches(s, cx)) + } else { + Task::ready(vec![]) + } + } + + /// Reports back to the search toolbar what the active match should be (the selection) + fn active_match_index( + &mut self, + direction: Direction, + matches: &[Self::Match], + _token: SearchToken, + _: &mut Window, + cx: &mut Context, + ) -> Option { + // Selection head might have a value if there's a selection that isn't + // associated with a match. Therefore, if there are no matches, we should + // report None, no matter the state of the terminal + + if !matches.is_empty() { + if let Some(selection_head) = self.terminal().read(cx).selection_head { + // If selection head is contained in a match. Return that match + match direction { + Direction::Prev => { + // If no selection before selection head, return the first match + Some( + matches + .iter() + .enumerate() + .rev() + .find(|(_, search_match)| { + search_match.contains(selection_head) + || search_match.start() < selection_head + }) + .map(|(ix, _)| ix) + .unwrap_or(0), + ) + } + Direction::Next => { + // If no selection after selection head, return the last match + Some( + matches + .iter() + .enumerate() + .find(|(_, search_match)| { + search_match.contains(selection_head) + || search_match.start() > selection_head + }) + .map(|(ix, _)| ix) + .unwrap_or(matches.len().saturating_sub(1)), + ) + } + } + } else { + // Matches found but no active selection, return the first last one (closest to cursor) + Some(matches.len().saturating_sub(1)) + } + } else { + None + } + } + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _token: SearchToken, + _window: &mut Window, + _: &mut Context, + ) { + // Replacement is not supported in terminal view, so this is a no-op. + } +} + +/// Gets the working directory for the given workspace, respecting the user's settings. +/// Falls back to home directory when no project directory is available. +/// +/// For remote projects, local-only resolution (home dir fallback, shell expansion, +/// local `is_dir` checks) is skipped -- returning `None` lets the remote shell +/// open in the remote user's home directory by default. +pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { + let is_remote = workspace.project().read(cx).is_remote(); + let directory = match &TerminalSettings::get_global(cx).working_directory { + WorkingDirectory::CurrentFileDirectory => workspace + .project() + .read(cx) + .active_entry_directory(cx) + .or_else(|| current_project_directory(workspace, cx)), + WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx), + WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), + WorkingDirectory::AlwaysHome => None, + WorkingDirectory::Always { directory } if !is_remote => shellexpand::full(directory) + .ok() + .map(|dir| Path::new(&dir.to_string()).to_path_buf()) + .filter(|dir| dir.is_dir()), + WorkingDirectory::Always { .. } => None, + }; + + if is_remote { + directory + } else { + directory.or_else(dirs::home_dir) + } +} + +fn current_project_directory(workspace: &Workspace, cx: &App) -> Option { + workspace + .project() + .read(cx) + .active_project_directory(cx) + .as_deref() + .map(Path::to_path_buf) + .or_else(|| first_project_directory(workspace, cx)) +} + +///Gets the first project's home directory, or the home directory +fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { + let worktree = workspace.worktrees(cx).next()?.read(cx); + let worktree_path = worktree.abs_path(); + if worktree.root_entry()?.is_dir() { + Some(worktree_path.to_path_buf()) + } else { + // If worktree is a file, return its parent directory + worktree_path.parent().map(|p| p.to_path_buf()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, VisualTestContext}; + use project::{Entry, Project, ProjectPath, Worktree}; + use remote::RemoteClient; + use std::path::{Path, PathBuf}; + use util::paths::PathStyle; + use util::rel_path::RelPath; + use workspace::item::test::{TestItem, TestProjectItem}; + use workspace::{AppState, MultiWorkspace, SelectedEntry}; + + fn expected_drop_text(paths: &[PathBuf]) -> String { + let mut text = String::new(); + for path in paths { + text.push(' '); + text.push_str(&shlex::try_quote(path.to_str().unwrap()).unwrap()); + } + text.push(' '); + text + } + + fn assert_drop_writes_to_terminal( + pane: &Entity, + terminal_view_index: usize, + terminal: &Entity, + dropped: &dyn Any, + expected_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let _ = terminal.update(cx, |terminal, _| terminal.take_input_log()); + + let handled = pane.update(cx, |pane, cx| { + pane.item_for_index(terminal_view_index) + .unwrap() + .handle_drop(pane, dropped, window, cx) + }); + assert!(handled, "handle_drop should return true for {:?}", dropped); + + let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert_eq!(input_log.len(), 1, "expected exactly one write to terminal"); + let written = + String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8"); + assert_eq!(written, expected_text); + } + + // DEC private mode 1049: a program writes this to enter the alternate screen buffer. + const ENTER_ALT_SCREEN: &[u8] = b"\x1b[?1049h"; + + // CSI `1;2A` = cursor-up with the xterm Shift modifier (`1 + 1` for Shift). + const SHIFT_UP_ESCAPE: &[u8] = b"\x1b[1;2A"; + + #[gpui::test] + async fn edit_menu_copy_and_paste_are_available_when_terminal_is_focused( + cx: &mut TestAppContext, + ) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + assert!(window.is_action_available(&editor::actions::Copy, cx)); + assert!(window.is_action_available(&editor::actions::Paste, cx)); + + cx.write_to_clipboard(gpui::ClipboardItem::new_string("foo".to_string())); + terminal.update(cx, |terminal, _| terminal.take_input_log()); + window.dispatch_action(Box::new(editor::actions::Paste), cx); + }); + cx.run_until_parked(); + + cx.update(|_, cx| { + let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert_eq!(input_log, vec![b"foo".to_vec()]); + }); + } + + #[gpui::test] + async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + let output = (0..200) + .map(|line| format!("line {line}\n")) + .collect::(); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(output.as_bytes(), cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(!terminal.last_content.mode.contains(Modes::ALT_SCREEN)); + assert_eq!(terminal.last_content.display_offset, 0); + }); + + cx.simulate_keystrokes("shift-up"); + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| terminal.sync(window, cx)); + }); + + assert_eq!( + terminal.read_with(&cx, |terminal, _| terminal.last_content.display_offset), + 1, + "shift-up should scroll terminal history in the normal screen", + ); + assert!( + terminal + .update(&mut cx, |terminal, _| terminal.take_input_log()) + .is_empty(), + "shift-up in the normal screen should not be forwarded to the shell", + ); + } + + #[gpui::test] + async fn shift_up_is_forwarded_to_program_in_alt_screen(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + terminal.update(cx, |terminal, cx| { + terminal.write_output(ENTER_ALT_SCREEN, cx); + terminal.sync(window, cx); + }); + }); + terminal.read_with(&cx, |terminal, _| { + assert!(terminal.last_content.mode.contains(Modes::ALT_SCREEN)); + }); + + cx.simulate_keystrokes("shift-up"); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![SHIFT_UP_ESCAPE.to_vec()], + "shift-up should be forwarded to the program in the alternate screen", + ); + } + + #[cfg(target_os = "linux")] + #[gpui::test] + async fn ctrl_q_is_forwarded_to_terminal_not_quit(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(load_default_keymap); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-q"); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![vec![0x11]], + "ctrl-q in a focused terminal should send 0x11 to the PTY, not trigger zed::Quit", + ); + } + + // Working directory calculation tests + + // No Worktrees in project -> home_dir() + #[gpui::test] + async fn no_worktree(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + cx.read(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + //Make sure environment is as expected + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_none()); + + let res = default_working_directory(workspace, cx); + assert_eq!(res, dirs::home_dir()); + let res = first_project_directory(workspace, cx); + assert_eq!(res, None); + }); + } + + #[gpui::test] + async fn remote_no_worktree_uses_remote_shell_default_cwd( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) { + let (_project, workspace) = init_remote_test(cx, server_cx).await; + + cx.read(|cx| { + let workspace = workspace.read(cx); + + assert!(workspace.project().read(cx).is_remote()); + assert!(workspace.worktrees(cx).next().is_none()); + assert_eq!(default_working_directory(workspace, cx), None); + }); + } + + // No active entry, but a worktree, worktree is a file -> parent directory + #[gpui::test] + async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + + create_file_wt(project.clone(), "/root.txt", cx).await; + cx.read(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + //Make sure environment is as expected + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_some()); + + let res = default_working_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/").to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/").to_path_buf())); + }); + } + + // No active entry, but a worktree, worktree is a folder -> worktree_folder + #[gpui::test] + async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + + let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; + cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_none()); + assert!(workspace.worktrees(cx).next().is_some()); + + let res = default_working_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root/").to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root/").to_path_buf())); + }); + } + + // Active entry with a work tree, worktree is a file -> worktree_folder() + #[gpui::test] + async fn active_entry_worktree_is_file(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + + let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await; + let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await; + insert_active_entry_for(wt2, entry2, project.clone(), cx); + + cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_some()); + + let res = default_working_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); + }); + } + + // Active entry, with a worktree, worktree is a folder -> worktree_folder + #[gpui::test] + async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + + let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await; + let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await; + insert_active_entry_for(wt2, entry2, project.clone(), cx); + + cx.update(|cx| { + let workspace = workspace.read(cx); + let active_entry = project.read(cx).active_entry(); + + assert!(active_entry.is_some()); + + let res = default_working_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root2/").to_path_buf())); + let res = first_project_directory(workspace, cx); + assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); + }); + } + + // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory) + #[gpui::test] + async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) { + let (project, _workspace) = init_test(cx).await; + + let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; + + cx.update(|cx| { + assert!(project.read(cx).active_entry().is_none()); + + let res = project.read(cx).active_entry_directory(cx); + assert_eq!(res, None); + }); + } + + // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory) + #[gpui::test] + async fn active_entry_directory_active_file(cx: &mut TestAppContext) { + let (project, _workspace) = init_test(cx).await; + + let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; + let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await; + insert_active_entry_for(wt, entry, project.clone(), cx); + + cx.update(|cx| { + let res = project.read(cx).active_entry_directory(cx); + assert_eq!(res, Some(Path::new("/root/src").to_path_buf())); + }); + } + + // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory) + #[gpui::test] + async fn active_entry_directory_active_dir(cx: &mut TestAppContext) { + let (project, _workspace) = init_test(cx).await; + + let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await; + insert_active_entry_for(wt, entry, project.clone(), cx); + + cx.update(|cx| { + let res = project.read(cx).active_entry_directory(cx); + assert_eq!(res, Some(Path::new("/root/").to_path_buf())); + }); + } + + /// Creates a worktree with 1 file: /root.txt + pub async fn init_test(cx: &mut TestAppContext) -> (Entity, Entity) { + let (project, workspace, _) = init_test_with_window(cx).await; + (project, workspace) + } + + fn load_default_keymap(cx: &mut App) { + cx.bind_keys( + settings::KeymapFile::load_asset_allow_partial_failure( + settings::DEFAULT_KEYMAP_PATH, + cx, + ) + .unwrap(), + ); + } + + fn add_display_only_terminal( + project: &Entity, + window_handle: gpui::WindowHandle, + focus: bool, + cx: &mut TestAppContext, + ) -> (Entity, Entity, Entity) { + let project = project.clone(); + window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let active_pane = workspace.read(cx).active_pane().clone(); + + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + active_pane.update(cx, |pane, cx| { + pane.add_item( + Box::new(terminal_view.clone()), + true, + false, + None, + window, + cx, + ); + }); + + if focus { + let focus_handle = terminal_view.read(cx).focus_handle.clone(); + focus_handle.focus(window, cx); + } + + (active_pane, terminal, terminal_view) + }) + .unwrap() + } + + /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. + async fn init_test_with_window( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + gpui::WindowHandle, + ) { + let params = cx.update(AppState::test); + cx.update(|cx| { + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + + let project = Project::test(params.fs.clone(), [], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + (project, workspace, window_handle) + } + + async fn init_remote_test( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) -> (Entity, Entity) { + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let params = cx.update(AppState::test); + let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx); + let ping_handler = server_cx.new(|_| ()); + server_session.add_request_handler::( + ping_handler.downgrade(), + |_entity, _envelope, _cx| async { Ok(rpc::proto::Ack {}) }, + ); + drop(connect_guard); + + let remote_client = RemoteClient::connect_mock(opts, cx).await; + let project = cx.update(|cx| { + Project::remote( + remote_client, + params.client.clone(), + params.node_runtime.clone(), + params.user_store.clone(), + params.languages.clone(), + params.fs.clone(), + false, + cx, + ) + }); + + let window_handle = cx.add_window({ + let params = params.clone(); + let project_for_workspace = project.clone(); + move |window, cx| { + window.activate_window(); + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_for_workspace.clone(), + params.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, window, cx) + } + }); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + (project, workspace) + } + + /// Creates a file in the given worktree and returns its entry. + async fn create_file_in_worktree( + worktree: Entity, + relative_path: impl AsRef, + cx: &mut TestAppContext, + ) -> Entry { + cx.update(|cx| { + worktree.update(cx, |worktree, cx| { + worktree.create_entry( + RelPath::new(relative_path.as_ref(), PathStyle::local()) + .unwrap() + .as_ref() + .into(), + false, + None, + cx, + ) + }) + }) + .await + .unwrap() + .into_included() + .unwrap() + } + + /// Creates a worktree with 1 folder: /root{suffix}/ + async fn create_folder_wt( + project: Entity, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (Entity, Entry) { + create_wt(project, true, path, cx).await + } + + /// Creates a worktree with 1 file: /root{suffix}.txt + async fn create_file_wt( + project: Entity, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (Entity, Entry) { + create_wt(project, false, path, cx).await + } + + async fn create_wt( + project: Entity, + is_dir: bool, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (Entity, Entry) { + let (wt, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path, true, cx) + }) + .await + .unwrap(); + + let entry = cx + .update(|cx| { + wt.update(cx, |wt, cx| { + wt.create_entry(RelPath::empty_arc(), is_dir, None, cx) + }) + }) + .await + .unwrap() + .into_included() + .unwrap(); + + (wt, entry) + } + + pub fn insert_active_entry_for( + wt: Entity, + entry: Entry, + project: Entity, + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + let p = ProjectPath { + worktree_id: wt.read(cx).id(), + path: entry.path, + }; + project.update(cx, |project, cx| project.set_active_path(Some(p), cx)); + }); + } + + // Terminal drag/drop test + + #[gpui::test] + async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + + let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await; + let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await; + let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await; + + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let first_path = project + .read_with(cx, |project, cx| { + project.absolute_path( + &ProjectPath { + worktree_id, + path: first_entry.path.clone(), + }, + cx, + ) + }) + .unwrap(); + let second_path = project + .read_with(cx, |project, cx| { + project.absolute_path( + &ProjectPath { + worktree_id, + path: second_entry.path.clone(), + }, + cx, + ) + }) + .unwrap(); + + let (active_pane, terminal, terminal_view) = + add_display_only_terminal(&project, window_handle, false, cx); + + let tab_item = window_handle + .update(cx, |_, window, cx| { + let tab_project_item = cx.new(|_| TestProjectItem { + entry_id: Some(second_entry.id), + project_path: Some(ProjectPath { + worktree_id, + path: second_entry.path.clone(), + }), + is_dirty: false, + }); + let tab_item = + cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item])); + active_pane.update(cx, |pane, cx| { + pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx); + }); + tab_item + }) + .unwrap(); + + cx.run_until_parked(); + + window_handle + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let terminal_view_index = + active_pane.read(cx).index_for_item(&terminal_view).unwrap(); + let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap(); + + assert!( + workspace.read(cx).pane_for(&terminal_view).is_some(), + "terminal view not registered with workspace after run_until_parked" + ); + + // Dragging an external file should write its path to the terminal + let external_paths = ExternalPaths(vec![first_path.clone()].into()); + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &external_paths, + &expected_drop_text(std::slice::from_ref(&first_path)), + window, + cx, + ); + + // Dragging a tab should write the path of the tab's item to the terminal + let dragged_tab = DraggedTab { + pane: active_pane.clone(), + item: Box::new(tab_item.clone()), + ix: dragged_tab_index, + detail: 0, + is_active: false, + }; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dragged_tab, + &expected_drop_text(std::slice::from_ref(&second_path)), + window, + cx, + ); + + // Dragging multiple selections should write both paths to the terminal + let dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: first_entry.id, + }, + marked_selections: Arc::from([ + SelectedEntry { + worktree_id, + entry_id: first_entry.id, + }, + SelectedEntry { + worktree_id, + entry_id: second_entry.id, + }, + ]), + }; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dragged_selection, + &expected_drop_text(&[first_path.clone(), second_path.clone()]), + window, + cx, + ); + + // Dropping a project entry should write the entry's path to the terminal + let dropped_entry_id = first_entry.id; + assert_drop_writes_to_terminal( + &active_pane, + terminal_view_index, + &terminal, + &dropped_entry_id, + &expected_drop_text(&[first_path]), + window, + cx, + ); + }) + .unwrap(); + } + + // Terminal rename tests + + #[gpui::test] + async fn test_custom_title_initially_none(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, _cx| { + assert!(view.custom_title().is_none()); + }); + } + + #[gpui::test] + async fn test_set_custom_title(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, cx| { + view.set_custom_title(Some("frontend".to_string()), cx); + assert_eq!(view.custom_title(), Some("frontend")); + }); + } + + #[gpui::test] + async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, cx| { + view.set_custom_title(Some("test".to_string()), cx); + assert_eq!(view.custom_title(), Some("test")); + + view.set_custom_title(Some("".to_string()), cx); + assert!(view.custom_title().is_none()); + + view.set_custom_title(Some(" ".to_string()), cx); + assert!(view.custom_title().is_none()); + }); + } + + #[gpui::test] + async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, cx| { + view.needs_serialize = false; + view.set_custom_title(Some("new_label".to_string()), cx); + assert!(view.needs_serialize); + }); + } + + #[gpui::test] + async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, cx| { + view.set_custom_title(Some("my-server".to_string()), cx); + let text = view.tab_content_text(0, cx); + assert_eq!(text.as_ref(), "my-server"); + }); + + terminal_view.update(cx, |view, cx| { + view.set_custom_title(None, cx); + let text = view.tab_content_text(0, cx); + assert_ne!(text.as_ref(), "my-server"); + }); + } + + async fn draw_standalone_terminal( + output: &[u8], + cx: &mut TestAppContext, + ) -> (gpui::Bounds, gpui::Size) { + let (project, workspace) = init_test(cx).await; + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + terminal.update(cx, |terminal, cx| { + terminal.write_output(output, cx); + }); + + let (terminal_view, cx) = cx.add_window_view(|window, cx| { + TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + let draw_size = gpui::size(px(400.), px(201.)); + cx.simulate_resize(draw_size); + cx.draw(gpui::Point::default(), draw_size, |_, _| { + terminal_view.clone().into_any_element() + }); + cx.run_until_parked(); + cx.draw(gpui::Point::default(), draw_size, |_, _| { + terminal_view.clone().into_any_element() + }); + + let bounds = terminal.read_with(cx, |terminal, _| { + terminal.last_content().terminal_bounds.bounds + }); + (bounds, draw_size) + } + + #[gpui::test] + async fn test_short_standalone_terminal_stays_top_anchored_on_resize(cx: &mut TestAppContext) { + let (bounds, _) = draw_standalone_terminal(b"$ ", cx).await; + assert_eq!(bounds.origin.y, px(0.)); + } + + #[gpui::test] + async fn test_full_standalone_terminal_stays_bottom_anchored_on_resize( + cx: &mut TestAppContext, + ) { + let (bounds, draw_size) = draw_standalone_terminal( + b"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n", + cx, + ) + .await; + assert!(bounds.origin.y > px(0.)); + assert_eq!(bounds.bottom(), draw_size.height); + } + + #[gpui::test] + async fn test_short_alt_screen_stays_bottom_anchored_on_resize(cx: &mut TestAppContext) { + let (bounds, draw_size) = draw_standalone_terminal(b"\x1b[?1049h$ ", cx).await; + assert!(bounds.origin.y > px(0.)); + assert_eq!(bounds.bottom(), draw_size.height); + } + + #[gpui::test] + async fn test_inline_terminal_displays_all_of_its_lines(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + let (terminal_view, cx) = cx.add_window_view(|window, cx| { + let mut terminal_view = TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ); + terminal_view.set_embedded_mode(None, cx); + terminal_view + }); + + for _ in 1..=20 { + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"line\n", cx); + }); + cx.draw( + gpui::Point::default(), + gpui::size(px(400.), px(100.)), + |_, _| terminal_view.clone().into_any_element(), + ); + terminal.read_with(cx, |terminal, _| { + assert_eq!(terminal.viewport_lines(), terminal.total_lines()); + }) + } + } + + #[gpui::test] + async fn test_inline_terminal_shrinks_after_clear(cx: &mut TestAppContext) { + let (project, workspace) = init_test(cx).await; + let terminal = cx.new(|cx| { + terminal::TerminalBuilder::new_display_only( + CursorShape::default(), + terminal::terminal_settings::AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + let (terminal_view, cx) = cx.add_window_view(|window, cx| { + let mut terminal_view = TerminalView::new( + terminal.clone(), + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ); + terminal_view.set_embedded_mode(None, cx); + terminal_view + }); + + for _ in 1..=20 { + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"line\n", cx); + }); + cx.draw( + gpui::Point::default(), + gpui::size(px(400.), px(100.)), + |_, _| terminal_view.clone().into_any_element(), + ); + } + terminal.read_with(cx, |terminal, _| { + assert_eq!(terminal.total_lines(), 21); + }); + + terminal.update(cx, |terminal, _| terminal.clear()); + for _ in 1..=2 { + cx.draw( + gpui::Point::default(), + gpui::size(px(400.), px(100.)), + |_, _| terminal_view.clone().into_any_element(), + ); + } + terminal.read_with(cx, |terminal, _| { + assert_eq!(terminal.total_lines(), 1); + assert_eq!(terminal.viewport_lines(), 1); + }); + } + + #[gpui::test] + async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty( + cx: &mut TestAppContext, + ) { + cx.executor().allow_parking(); + + let (project, workspace) = init_test(cx).await; + + let terminal = project + .update(cx, |project, cx| project.create_terminal_shell(None, cx)) + .await + .unwrap(); + + let terminal_view = cx + .add_window(|window, cx| { + TerminalView::new( + terminal, + workspace.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }) + .root(cx) + .unwrap(); + + terminal_view.update(cx, |view, cx| { + view.custom_title = Some("".to_string()); + let text = view.tab_content_text(0, cx); + assert!( + !text.is_empty(), + "Tab should show terminal title, not empty string; got: '{}'", + text + ); + }); + + terminal_view.update(cx, |view, cx| { + view.custom_title = Some(" ".to_string()); + let text = view.tab_content_text(0, cx); + assert!( + !text.is_empty() && text.as_ref() != " ", + "Tab should show terminal title, not whitespace; got: '{}'", + text + ); + }); + } +} From 97ebd973efbec2d13c65acf0b594efd5329b05a2 Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 01:03:05 +0800 Subject: [PATCH 030/110] Add macOS development disk image builder --- README.md | 10 ++++ scripts/build-dev-dmg.sh | 116 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100755 scripts/build-dev-dmg.sh diff --git a/README.md b/README.md index 0cfb15db..ae997c2b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ Windows is deferred because Herdr currently uses Unix-domain sockets. Wry's in-window Linux child webviews require X11, so Chartr selects the same GPUI backend instead of exposing web panes that fail only on Wayland. +For a macOS development disk image, run: + +```sh +scripts/build-dev-dmg.sh +``` + +It produces an ad-hoc-signed, unnotarized `Chartr Dev.app` disk image and SHA-256 +sidecar under `target/`. Pass an output path as the script's only argument to +place the image elsewhere. + ## Spaces, panes, and items One window owns ordered spaces and one active space, following Zed's diff --git a/scripts/build-dev-dmg.sh b/scripts/build-dev-dmg.sh new file mode 100755 index 00000000..e2011844 --- /dev/null +++ b/scripts/build-dev-dmg.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# Build an ad-hoc-signed macOS disk image for local testing. +# +# The application uses Cargo's release profile so the GPUI terminal is +# representative of a distributable build. "Dev" describes the package: +# it has a separate bundle identifier, an ad-hoc signature, and no notarization. + +set -eu + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +root=$(cd "$script_dir/.." && pwd) + +if [ "$#" -gt 1 ]; then + echo "usage: $0 [output.dmg]" >&2 + exit 2 +fi + +version=$(sed -n 's/^version = "\([^"]*\)"$/\1/p' "$root/Cargo.toml" | head -1) +[ -n "$version" ] || { + echo "cannot read the workspace version from Cargo.toml" >&2 + exit 1 +} + +revision=$(git -C "$root" rev-parse --short=12 HEAD) +build_number=$(git -C "$root" rev-list --count HEAD) +architecture=$(uname -m) +case "$architecture" in +arm64 | x86_64) ;; +*) + echo "unsupported macOS architecture: $architecture" >&2 + exit 1 + ;; +esac + +default_output="$root/target/Chartr-dev-$version-$revision-$architecture.dmg" +output=${1:-$default_output} +case "$output" in +/*) ;; +*) output="$root/$output" ;; +esac + +echo "building Chartr $version ($revision)" +cargo build --manifest-path "$root/Cargo.toml" --release -p zeddy --locked + +binary="$root/target/release/zeddy" +sidecar="$root/target/release/herdr" +[ -x "$binary" ] && [ -x "$sidecar" ] || { + echo "release build did not produce zeddy and herdr" >&2 + exit 1 +} + +expected_herdr=$(sed -n \ + 's/^pub const SUPPORTED_HERDR_VERSION: &str = "\(.*\)";$/\1/p' \ + "$root/crates/zeddy-herdr/src/lib.rs") +actual_herdr=$("$sidecar" --version | sed 's/^herdr //') +[ "$actual_herdr" = "$expected_herdr" ] || { + echo "Herdr sidecar is $actual_herdr; expected $expected_herdr" >&2 + exit 1 +} + +mkdir -p "$root/target" +work=$(mktemp -d "$root/target/dev-dmg-stage.XXXXXX") +trap 'rm -rf "$work"' EXIT +trap 'exit 1' HUP INT TERM + +image_root="$work/image" +app="$image_root/Chartr Dev.app" +macos="$app/Contents/MacOS" +resources="$app/Contents/Resources" +mkdir -p "$macos" "$resources" +ditto "$binary" "$macos/Chartr" +ditto "$sidecar" "$macos/herdr" + +plist="$app/Contents/Info.plist" +plutil -create xml1 "$plist" +plutil -insert CFBundleDevelopmentRegion -string en "$plist" +plutil -insert CFBundleDisplayName -string "Chartr Dev" "$plist" +plutil -insert CFBundleExecutable -string Chartr "$plist" +plutil -insert CFBundleIdentifier -string dev.chartr.zeddy.dev "$plist" +plutil -insert CFBundleInfoDictionaryVersion -string 6.0 "$plist" +plutil -insert CFBundleName -string "Chartr Dev" "$plist" +plutil -insert CFBundlePackageType -string APPL "$plist" +plutil -insert CFBundleShortVersionString -string "$version" "$plist" +plutil -insert CFBundleVersion -string "$build_number" "$plist" +plutil -insert ChartrGitRevision -string "$revision" "$plist" +plutil -insert LSApplicationCategoryType -string public.app-category.developer-tools "$plist" +plutil -insert NSHighResolutionCapable -bool YES "$plist" + +minimum_macos=$(otool -l "$binary" | awk '$1 == "minos" { print $2; exit }') +[ -n "$minimum_macos" ] || minimum_macos=11.0 +plutil -insert LSMinimumSystemVersion -string "$minimum_macos" "$plist" +plutil -lint "$plist" + +codesign --force --sign - --timestamp=none \ + --identifier dev.chartr.zeddy.dev.herdr "$macos/herdr" +codesign --force --sign - --timestamp=none \ + --identifier dev.chartr.zeddy.dev "$macos/Chartr" +codesign --force --sign - --timestamp=none \ + --identifier dev.chartr.zeddy.dev "$app" +codesign --verify --deep --strict --verbose=2 "$app" + +ln -s /Applications "$image_root/Applications" +temporary_dmg="$work/Chartr-dev.dmg" +hdiutil create -quiet -volname "Chartr Dev $version" -srcfolder "$image_root" \ + -fs HFS+ -format UDZO "$temporary_dmg" +hdiutil verify "$temporary_dmg" + +mkdir -p "$(dirname "$output")" +mv -f "$temporary_dmg" "$output" +( + cd "$(dirname "$output")" + shasum -a 256 "$(basename "$output")" +) > "$output.sha256" + +echo "built $output" +echo "checksum: $output.sha256" From df36e5938b462798763f28104e2894e79e7d3b1a Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 01:11:07 +0800 Subject: [PATCH 031/110] Fix implicit root space on launch --- README.md | 6 ++ crates/zeddy/src/app.rs | 173 ++++++++++++++++++++++++++++---- crates/zeddy/src/main.rs | 66 +++++++++++- crates/zeddy/src/persistence.rs | 31 ++++++ 4 files changed, 252 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ae997c2b..92e53932 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ sh vendor/herdr/fetch.sh cargo run -p zeddy ``` +Pass a folder explicitly when it should be registered and opened at launch: + +```sh +cargo run -p zeddy -- . +``` + The workspace build requires Zig 0.16.0 for its pinned libghostty terminal input encoder. The sidecar fetch currently builds an immutable post-0.8.2 Herdr revision, because the latest tagged release drops non-wheel mouse input diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 15ef192b..35e2d47b 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -159,7 +159,12 @@ pub struct Zeddy { } impl Zeddy { - pub fn new(cwd: PathBuf, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + cwd: PathBuf, + opened_path: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { let focus = cx.focus_handle(); cx.on_focus_in(&focus, window, |this, window, cx| { this.focus_active_terminal(window, cx); @@ -192,7 +197,7 @@ impl Zeddy { cx.notify(); }) .detach(); - let (state, saved, state_problem) = + let (mut state, mut saved, mut state_problem) = match crate::persistence::state_file().and_then(StateStore::open) { Ok(store) => match store.load() { Ok(saved) => (Some(store), saved, None), @@ -247,7 +252,38 @@ impl Zeddy { } }; - let (registry, registry_problem) = load_registry(&cwd); + let (mut registry, mut registry_problem) = load_registry(opened_path.as_deref()); + if opened_path.is_none() && cwd.parent().is_none() { + let cleanup_pending = match state.as_ref() { + Some(state) => match state.implicit_root_cleanup_pending() { + Ok(pending) => pending, + Err(error) => { + state_problem = Some(error.to_string()); + false + } + }, + None => true, + }; + if cleanup_pending { + let cleanup = registry + .as_mut() + .map(|registry| cleanup_empty_implicit_root(registry, &mut saved, &cwd)) + .transpose(); + match cleanup { + Ok(Some(changed)) => { + if let Some(state) = state.as_mut() { + let result = if changed { state.save(&saved) } else { Ok(()) } + .and_then(|_| state.complete_implicit_root_cleanup()); + if let Err(error) = result { + state_problem = Some(error.to_string()); + } + } + } + Ok(None) => {} + Err(error) => registry_problem = Some(error.to_string()), + } + } + } let mut descriptors = Vec::new(); let home = settings .resolved() @@ -306,23 +342,23 @@ impl Zeddy { space.update(cx, |space, _| space.restore_saved(saved_space)); } } - let active = saved - .window - .active_space + let active = opened_path .as_ref() - .and_then(|key| { - spaces.iter().find(|space| space.read(cx).persisted().key == *key).cloned() - }) - .or_else(|| { + .and_then(|opened_path| { spaces .iter() .find(|space| { let space = space.read(cx); space.kind() == SpaceKind::Registered - && spaces::same_path(space.path(), &cwd) + && spaces::same_path(space.path(), opened_path) }) .cloned() }) + .or_else(|| { + saved.window.active_space.as_ref().and_then(|key| { + spaces.iter().find(|space| space.read(cx).persisted().key == *key).cloned() + }) + }) .or_else(|| spaces.first().cloned()); let catalog = zeddy_plugin_host::load_all_where( @@ -3653,7 +3689,7 @@ fn pane_resize_handle(dragged: DraggedPaneDivider, axis: PaneAxisDirection) -> i .occlude() } -fn load_registry(cwd: &std::path::Path) -> (Option, Option) { +fn load_registry(opened_path: Option<&std::path::Path>) -> (Option, Option) { let file = match spaces::spaces_file() { Ok(file) => file, Err(error) => return (None, Some(error.to_string())), @@ -3662,18 +3698,52 @@ fn load_registry(cwd: &std::path::Path) -> (Option, Option) { Ok(registry) => registry, Err(error) => return (None, Some(error.to_string())), }; - // Launching zeddy in a folder is the command-line equivalent of Zed's - // `zed `: the opened project joins the persisted recent/space list. - let is_ad_hoc_home = std::env::home_dir().is_some_and(|home| spaces::same_path(&home, cwd)); - if !is_ad_hoc_home - && !registry.spaces().iter().any(|space| spaces::same_path(space.path(), cwd)) - && let Err(error) = registry.register(cwd) - { - return (Some(registry), Some(error.to_string())); + // Only an explicit command-line path is equivalent to Zed's `zed `. + // The inherited process working directory belongs to the desktop launcher. + if let Some(opened_path) = opened_path { + let is_ad_hoc_home = + std::env::home_dir().is_some_and(|home| spaces::same_path(&home, opened_path)); + if !is_ad_hoc_home + && !registry.spaces().iter().any(|space| spaces::same_path(space.path(), opened_path)) + && let Err(error) = registry.register(opened_path) + { + return (Some(registry), Some(error.to_string())); + } } (Some(registry), None) } +/// Remove the legacy root row only when it cannot own anything. An explicit +/// `Chartr /` launch bypasses this migration, and a root space with items is +/// retained so cleanup can never orphan a live terminal or plugin. +fn cleanup_empty_implicit_root( + registry: &mut Registry, + saved: &mut Snapshot, + root: &std::path::Path, +) -> Result { + let saved_root_has_items = saved.spaces.iter().any(|space| { + space.kind == PersistedSpaceKind::Folder + && space.path.as_deref().is_some_and(|path| spaces::same_path(path, root)) + && !space.items.is_empty() + }); + if saved_root_has_items { + return Ok(false); + } + + let registry_changed = registry.remove(root)?; + let previous_len = saved.spaces.len(); + saved.spaces.retain(|space| { + space.kind != PersistedSpaceKind::Folder + || !space.path.as_deref().is_some_and(|path| spaces::same_path(path, root)) + }); + let state_changed = saved.spaces.len() != previous_len; + let root_key = format!("folder:{}", root.display()); + if state_changed && saved.window.active_space.as_deref() == Some(root_key.as_str()) { + saved.window.active_space = Some("ad-hoc".to_owned()); + } + Ok(registry_changed || state_changed) +} + fn message(text: &str, cx: &App) -> impl IntoElement { v_flex() .size_full() @@ -3706,9 +3776,12 @@ fn plugin_paths() -> Paths { #[cfg(test)] mod pane_drop_tests { use super::{ - SplitDirection, pane_drop_direction_for_position, regex_escape_literal, - resolve_terminal_path, split_direction_for_position, + PersistedSpaceKind, Registry, Snapshot, SplitDirection, cleanup_empty_implicit_root, + pane_drop_direction_for_position, regex_escape_literal, resolve_terminal_path, + split_direction_for_position, }; + use crate::{persistence::PersistedSpace, workspace::WorkspaceTabs}; + use std::path::{Path, PathBuf}; #[test] fn terminal_search_treats_user_text_as_a_literal() { @@ -3767,4 +3840,60 @@ mod pane_drop_tests { assert_eq!(split_direction_for_position(100., 100., 92., 97.), Some(SplitDirection::Down)); assert_eq!(split_direction_for_position(100., 100., 3., 90.), Some(SplitDirection::Left)); } + + #[test] + fn migration_removes_the_empty_root_space_from_registry_and_state() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("spaces.toml"); + let mut registry = Registry::load(&file).unwrap(); + registry.register(Path::new("/")).unwrap(); + let mut saved = Snapshot { + spaces: vec![PersistedSpace { + key: "folder:/".to_owned(), + name: "/".to_owned(), + path: Some(PathBuf::from("/")), + kind: PersistedSpaceKind::Folder, + layout: WorkspaceTabs::new(), + items: Vec::new(), + expanded: true, + }], + ..Snapshot::default() + }; + saved.window.active_space = Some("folder:/".to_owned()); + + assert!(cleanup_empty_implicit_root(&mut registry, &mut saved, Path::new("/")).unwrap()); + assert!(registry.spaces().is_empty()); + assert!(saved.spaces.is_empty()); + assert_eq!(saved.window.active_space.as_deref(), Some("ad-hoc")); + assert!(Registry::load(file).unwrap().spaces().is_empty()); + } + + #[test] + fn migration_keeps_a_root_space_that_owns_items() { + let temp = tempfile::tempdir().unwrap(); + let mut registry = Registry::load(temp.path().join("spaces.toml")).unwrap(); + registry.register(Path::new("/")).unwrap(); + let mut saved = Snapshot { + spaces: vec![PersistedSpace { + key: "folder:/".to_owned(), + name: "/".to_owned(), + path: Some(PathBuf::from("/")), + kind: PersistedSpaceKind::Folder, + layout: WorkspaceTabs::new(), + items: vec![crate::persistence::PersistedItem::Plugin { + item_id: 1, + plugin: "example.plugin".to_owned(), + pane: "main".to_owned(), + state: None, + bound_session: None, + }], + expanded: true, + }], + ..Snapshot::default() + }; + + assert!(!cleanup_empty_implicit_root(&mut registry, &mut saved, Path::new("/")).unwrap()); + assert_eq!(registry.spaces().len(), 1); + assert_eq!(saved.spaces.len(), 1); + } } diff --git a/crates/zeddy/src/main.rs b/crates/zeddy/src/main.rs index a6a4b587..34b26947 100644 --- a/crates/zeddy/src/main.rs +++ b/crates/zeddy/src/main.rs @@ -1,6 +1,9 @@ //! Chartr — a multi-space agent multiplexer. -use std::path::PathBuf; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; use gpui::{ App, AppContext as _, Bounds, Focusable as _, WindowBounds, WindowOptions, point, px, size, @@ -29,6 +32,7 @@ mod workspace; fn main() { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let opened_path = opened_path_from_args(std::env::args_os(), &cwd); application().with_assets(zed_assets::Assets).run(move |cx: &mut App| { // Zed's terminal model/view keeps its native emulator settings graph @@ -104,7 +108,8 @@ fn main() { ..Default::default() }, |window, cx| { - let view = cx.new(|cx| app::Zeddy::new(cwd.clone(), window, cx)); + let view = + cx.new(|cx| app::Zeddy::new(cwd.clone(), opened_path.clone(), window, cx)); window.focus(&view.read(cx).focus_handle(cx), cx); view }, @@ -126,3 +131,60 @@ fn main() { cx.activate(true); }); } + +/// Return only a folder explicitly passed to Chartr. +/// +/// A desktop launcher controls the process working directory; on macOS that is +/// commonly `/`. It is therefore never evidence that the operator opened a +/// project. Relative command-line paths still resolve against the shell's +/// working directory, as users expect from `Chartr .`. +fn opened_path_from_args(args: impl IntoIterator, cwd: &Path) -> Option { + let mut args = args.into_iter(); + args.next(); + let mut argument = args.next()?; + if argument == "--" { + argument = args.next()?; + } + // Older macOS launch services may inject this process-serial-number + // argument. It is launcher metadata, not a path selected by the user. + if argument.to_string_lossy().starts_with("-psn_") { + return None; + } + let path = PathBuf::from(argument); + Some(if path.is_absolute() { path } else { cwd.join(path) }) +} + +#[cfg(test)] +mod launch_tests { + use super::*; + + #[test] + fn a_plain_desktop_launch_does_not_open_its_inherited_working_directory() { + assert_eq!(opened_path_from_args([OsString::from("Chartr")], Path::new("/")), None); + assert_eq!( + opened_path_from_args( + [OsString::from("Chartr"), OsString::from("-psn_0_12345")], + Path::new("/"), + ), + None + ); + } + + #[test] + fn an_explicit_relative_path_is_resolved_from_the_shell_directory() { + assert_eq!( + opened_path_from_args( + [OsString::from("Chartr"), OsString::from("project")], + Path::new("/work"), + ), + Some(PathBuf::from("/work/project")) + ); + assert_eq!( + opened_path_from_args( + [OsString::from("Chartr"), OsString::from("--"), OsString::from(".")], + Path::new("/work"), + ), + Some(PathBuf::from("/work/.")) + ); + } +} diff --git a/crates/zeddy/src/persistence.rs b/crates/zeddy/src/persistence.rs index 253be239..15d77fd8 100644 --- a/crates/zeddy/src/persistence.rs +++ b/crates/zeddy/src/persistence.rs @@ -17,6 +17,7 @@ use crate::{mode::Mode, workspace::WorkspaceTabs}; pub const STATE_FILE: &str = "state.sqlite"; const SCHEMA_VERSION: i64 = 1; +const IMPLICIT_ROOT_CLEANUP: &str = "migration.implicit-root-space"; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -201,6 +202,28 @@ impl StateStore { Ok(()) } + /// Whether this installation still needs the one-time cleanup for builds + /// that mistook a desktop launcher's `/` working directory for a project. + pub fn implicit_root_cleanup_pending(&self) -> Result { + let completed = self + .connection + .query_row( + "SELECT 1 FROM app_state WHERE key = ?1", + [IMPLICIT_ROOT_CLEANUP], + |_| Ok(()), + ) + .optional()?; + Ok(completed.is_none()) + } + + pub fn complete_implicit_root_cleanup(&mut self) -> Result<()> { + self.connection.execute( + "INSERT OR REPLACE INTO app_state (key, value_json) VALUES (?1, 'true')", + [IMPLICIT_ROOT_CLEANUP], + )?; + Ok(()) + } + #[cfg(test)] fn schema_version(&self) -> Result { Ok(self.connection.pragma_query_value(None, "user_version", |row| row.get(0))?) @@ -286,6 +309,14 @@ mod tests { assert_eq!(store.load().unwrap().spaces.len(), 1); } + #[test] + fn one_time_migrations_have_an_explicit_completion_marker() { + let mut store = StateStore::memory().unwrap(); + assert!(store.implicit_root_cleanup_pending().unwrap()); + store.complete_implicit_root_cleanup().unwrap(); + assert!(!store.implicit_root_cleanup_pending().unwrap()); + } + #[test] fn state_paths_are_isolated_from_old_chartr() { assert_eq!( From 13f70ea9d1680a0d622557d14ab416ce9a564f96 Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 01:37:26 +0800 Subject: [PATCH 032/110] Polish workspace chrome spacing --- crates/zeddy/src/app.rs | 37 +++++++++++++++++++++++++++--- crates/zeddy/src/chrome/sidebar.rs | 4 ++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/zeddy/src/app.rs b/crates/zeddy/src/app.rs index 35e2d47b..633f48b2 100644 --- a/crates/zeddy/src/app.rs +++ b/crates/zeddy/src/app.rs @@ -2020,12 +2020,41 @@ impl Zeddy { } } - fn workspace_title_bar(&self, controls: Option<(AnyElement, AnyElement)>) -> AnyElement { + fn workspace_title_bar( + &self, + controls: Option<(AnyElement, AnyElement)>, + window: &Window, + cx: &App, + ) -> AnyElement { if !cfg!(target_os = "macos") { return self.title_bar.clone().into_any_element(); } - let mut overlays = Vec::with_capacity(2); + let colors = cx.theme().colors(); + let window_active = window.is_window_active(); + let mut overlays = Vec::with_capacity(4); + overlays.push( + div() + .absolute() + .top_0() + .right_0() + .bottom(px(1.)) + .left_0() + .bg(colors.panel_background) + .into_any_element(), + ); + if self.mode == Mode::Sidebar { + overlays.push( + div() + .absolute() + .left_0() + .bottom_0() + .w(px(self.sidebar_width - 1.)) + .h(px(1.)) + .bg(colors.panel_background) + .into_any_element(), + ); + } if let Some((space_switcher, view_menu)) = controls { overlays.push( h_flex() @@ -2035,6 +2064,7 @@ impl Zeddy { .top_0() .h(px(crate::title_bar::HEIGHT)) .max_w(px(200.)) + .when(!window_active, |controls| controls.opacity(0.65)) .child(space_switcher) .into_any_element(), ); @@ -2044,6 +2074,7 @@ impl Zeddy { .right(px(6.)) .top_0() .h(px(crate::title_bar::HEIGHT)) + .when(!window_active, |controls| controls.opacity(0.65)) .child(view_menu) .into_any_element(), ); @@ -3366,7 +3397,7 @@ impl Render for Zeddy { let emit: chrome::Emit = Rc::new(move |action, window, cx| on_action(&action, window, cx)); let title_controls = cfg!(target_os = "macos") .then(|| (self.space_switcher(window, cx), self.view_menu(emit.clone()))); - let title_bar = self.workspace_title_bar(title_controls); + let title_bar = self.workspace_title_bar(title_controls, window, cx); let workspace = v_flex() .flex_1() diff --git a/crates/zeddy/src/chrome/sidebar.rs b/crates/zeddy/src/chrome/sidebar.rs index 7f97bb81..e7a18ef2 100644 --- a/crates/zeddy/src/chrome/sidebar.rs +++ b/crates/zeddy/src/chrome/sidebar.rs @@ -645,7 +645,7 @@ pub fn render( .flex_1() .overflow_y_scroll() .track_scroll(sorter.scroll_handle()) - .py_1() + .pb_2() .px_1p5() .gap(CARD_GAP) .children(cards), @@ -795,7 +795,7 @@ fn row( wrapper.child( div() .absolute() - .right_0() + .right_1() .top_0() .bottom_0() .flex() From cb30e4e126b1e308477f5eba187d79cba01c934a Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 01:37:31 +0800 Subject: [PATCH 033/110] Balance terminal grid padding --- crates/zeddy/src/terminal_host.rs | 48 ++++++++++++++----- vendor/zed-terminal-view/CHARTR-PATCH.md | 11 +++-- .../zed-terminal-view/src/terminal_element.rs | 19 ++++++-- vendor/zed-terminal-view/src/terminal_view.rs | 15 ++++-- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/crates/zeddy/src/terminal_host.rs b/crates/zeddy/src/terminal_host.rs index 9c699ec3..fd513a2f 100644 --- a/crates/zeddy/src/terminal_host.rs +++ b/crates/zeddy/src/terminal_host.rs @@ -6,8 +6,9 @@ //! owns neither type. Invalid weak handles express that absence without //! manufacturing a partial Zed workspace; disabling workspace actions selects //! the view's documented non-workspace-host path. Chartr then selects the one -//! maintained host extension, top grid alignment; all terminal behavior remains -//! Zed's pinned model and view. +//! maintained host extensions: top grid alignment, balanced cell padding, and +//! an overlay scrollbar. All terminal behavior remains Zed's pinned model and +//! view. use gpui::{ App, AppContext as _, Div, Entity, Hsla, InteractiveElement as _, ParentElement as _, @@ -31,13 +32,14 @@ pub fn new_view( ); view.set_show_workspace_actions(false, cx); view.set_vertical_alignment(terminal_view::TerminalVerticalAlignment::Top, cx); + view.set_grid_padding(true, cx); view }) } /// Mount a TerminalView exactly as Zed mounts it: it fills the available pane /// without an additional product-level inset, and the TerminalElement remains -/// the innermost mouse target. The view itself owns its one-cell grid gutter. +/// the innermost mouse target. The view itself owns its balanced grid gutter. pub fn element(view: Entity, background: Hsla) -> Div { let drop_view = view.clone(); div() @@ -128,20 +130,18 @@ mod tests { assert!(terminal.read_with(cx, |terminal, _| terminal.used_lines()) >= 1); let initial_line_height = terminal.read_with(cx, |terminal, _| { let bounds = terminal.last_content().terminal_bounds; - assert_eq!(bounds.bounds.origin.y, px(0.)); - assert!(bounds.bounds.origin.x > px(0.)); - assert!(bounds.bounds.origin.x <= bounds.cell_width); + assert_balanced_padding(bounds, size(px(400.), px(201.))); bounds.line_height }); cx.simulate_resize(size(px(400.), px(202.))); cx.run_until_parked(); - assert_eq!( - terminal.read_with(cx, |terminal, _| { - terminal.last_content().terminal_bounds.bounds.origin.y - }), - px(0.) - ); + terminal.read_with(cx, |terminal, _| { + assert_balanced_padding( + terminal.last_content().terminal_bounds, + size(px(400.), px(202.)), + ); + }); let mut larger_typography = crate::settings::ResolvedSettings::default(); larger_typography.terminal_font_size = 19.; @@ -153,4 +153,28 @@ mod tests { assert_eq!(host.read_with(cx, |host, _| host.terminal.entity_id()), terminal.entity_id()); } + + fn assert_balanced_padding( + terminal: terminal::TerminalBounds, + viewport: gpui::Size, + ) { + let left = terminal.bounds.origin.x; + let top = terminal.bounds.origin.y; + let grid_right = left + terminal.cell_width * terminal.num_columns() as f32; + let grid_bottom = top + terminal.line_height * terminal.num_lines() as f32; + let right = viewport.width - grid_right; + let bottom = viewport.height - grid_bottom; + + assert!(left > px(0.)); + assert!(f32::from(top - left).abs() <= 1.); + assert!(right + px(1.) >= left); + assert!(right - left <= terminal.cell_width + px(1.)); + assert!(bottom + px(1.) >= top); + assert!( + bottom - top <= terminal.line_height + px(2.), + "top={top:?}, bottom={bottom:?}, line_height={:?}, lines={}", + terminal.line_height, + terminal.num_lines() + ); + } } diff --git a/vendor/zed-terminal-view/CHARTR-PATCH.md b/vendor/zed-terminal-view/CHARTR-PATCH.md index 2abe308a..99ceefad 100644 --- a/vendor/zed-terminal-view/CHARTR-PATCH.md +++ b/vendor/zed-terminal-view/CHARTR-PATCH.md @@ -3,17 +3,22 @@ This crate's source is an otherwise unchanged copy of Zed's `terminal_view` crate at commit `1ea16c1ab9dd6d36649e002dc60995634da04daf`. -Chartr adds one host policy: +Chartr adds three host policies: - `TerminalVerticalAlignment` and `TerminalView::set_vertical_alignment` let a non-Zed host choose whether spare sub-row pixels are placed above or below the terminal grid. - Zed's existing `BottomWhenFull` behavior remains the default. - Chartr selects `Top`, keeping the grid origin stable while a pane is resized. +- `TerminalView::set_grid_padding` gives a standalone grid one cell-width of + base padding on every edge. Chartr enables it; right and bottom may retain + fractional space because terminal dimensions use whole columns and rows. +- The vertical scrollbar overlays the terminal instead of reserving a stable + track, so an idle scrollbar does not create a permanent right-hand inset. When updating the pinned Zed revision, replace this directory from upstream -first, then reapply only the alignment enum, field, setter, layout branch, and -their tests. +first, then reapply only the alignment and padding fields, setters and layout +branches, the overlay-scrollbar render change, and their tests. `Cargo.toml` declares the upstream workspace dependencies explicitly so this crate can build from Chartr's workspace without pretending to be part of Zed's. diff --git a/vendor/zed-terminal-view/src/terminal_element.rs b/vendor/zed-terminal-view/src/terminal_element.rs index 049c4edb..b7ba1560 100644 --- a/vendor/zed-terminal-view/src/terminal_element.rs +++ b/vendor/zed-terminal-view/src/terminal_element.rs @@ -1281,7 +1281,7 @@ impl Element for TerminalElement { let text_system = cx.text_system(); let player_color = theme.players().local(); let match_color = theme.colors().search_match_background; - let gutter; + let horizontal_insets; let (dimensions, line_height_px) = { let rem_size = window.rem_size(); let font_pixels = text_style.font_size.to_pixels(rem_size); @@ -1292,10 +1292,18 @@ impl Element for TerminalElement { .advance(font_id, font_pixels, 'm') .unwrap() .width; - gutter = cell_width; + let padded_grid = { + let terminal_view = self.terminal_view.read(cx); + matches!(terminal_view.mode, TerminalMode::Standalone) + && terminal_view.grid_padding + }; + let trailing_padding = if padded_grid { cell_width } else { px(0.) }; + let vertical_padding = trailing_padding; + horizontal_insets = cell_width + trailing_padding; let mut size = bounds.size; - size.width -= gutter; + size.width -= horizontal_insets; + size.height = (size.height - vertical_padding * 2.).max(px(0.)); let available_height = size.height; // https://github.com/zed-industries/zed/issues/2750 @@ -1306,7 +1314,8 @@ impl Element for TerminalElement { } let mut origin = bounds.origin; - origin.x += gutter; + origin.x += cell_width; + origin.y += vertical_padding; if matches!(self.terminal_view.read(cx).mode, TerminalMode::Standalone) { let should_anchor_to_bottom = { @@ -1566,7 +1575,7 @@ impl Element for TerminalElement { let element = render(&mut block_cx); let mut element = div().occlude().child(element).into_any_element(); let available_space = size( - AvailableSpace::Definite(dimensions.width() + gutter), + AvailableSpace::Definite(dimensions.width() + horizontal_insets), AvailableSpace::Definite( block.height as f32 * dimensions.line_height(), ), diff --git a/vendor/zed-terminal-view/src/terminal_view.rs b/vendor/zed-terminal-view/src/terminal_view.rs index aa195627..6ba9f18a 100644 --- a/vendor/zed-terminal-view/src/terminal_view.rs +++ b/vendor/zed-terminal-view/src/terminal_view.rs @@ -139,6 +139,7 @@ pub struct TerminalView { blink_manager: Entity, mode: TerminalMode, vertical_alignment: TerminalVerticalAlignment, + grid_padding: bool, // Explicit override for whether workspace-specific context menu actions are shown. // When `None`, visibility is derived from `mode` (hidden for embedded terminals). show_workspace_actions: Option, @@ -304,6 +305,7 @@ impl TerminalView { hover_tooltip_update: Task::ready(()), mode: TerminalMode::Standalone, vertical_alignment: TerminalVerticalAlignment::default(), + grid_padding: false, show_workspace_actions: None, workspace_id, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, @@ -345,6 +347,14 @@ impl TerminalView { } } + /// Give every edge of a standalone grid the same one-cell base padding. + pub fn set_grid_padding(&mut self, padded: bool, cx: &mut Context) { + if self.grid_padding != padded { + self.grid_padding = padded; + cx.notify(); + } + } + /// Explicitly override whether workspace-specific context menu actions (e.g. creating or /// closing terminal tabs, inline assist) are shown. /// @@ -1432,14 +1442,9 @@ impl Render for TerminalView { self.mode.clone(), )) .when(self.content_mode(window, cx).is_scrollable(), |div| { - let colors = cx.theme().colors(); div.custom_scrollbars( Scrollbars::for_settings::() .show_along(ScrollAxes::Vertical) - .with_stable_track_along( - ScrollAxes::Vertical, - colors.editor_background, - ) .tracked_scroll_handle(&self.scroll_handle), window, cx, From 6d3eaacd1804f46ae493a542178d0795faec8025 Mon Sep 17 00:00:00 2001 From: John Goh Date: Thu, 3 Sep 2026 11:47:36 +0800 Subject: [PATCH 034/110] Add Chartr theme playground --- misc/theme-playground/.gitignore | 24 + misc/theme-playground/.oxlintrc.json | 8 + misc/theme-playground/README.md | 36 + misc/theme-playground/components.json | 18 + misc/theme-playground/index.html | 14 + misc/theme-playground/package-lock.json | 2797 +++++++++++++++++ misc/theme-playground/package.json | 37 + misc/theme-playground/public/favicon.svg | 1 + misc/theme-playground/public/icons.svg | 24 + misc/theme-playground/src/App.css | 735 +++++ misc/theme-playground/src/App.tsx | 176 ++ misc/theme-playground/src/assets/hero.png | Bin 0 -> 13057 bytes misc/theme-playground/src/assets/react.svg | 1 + misc/theme-playground/src/assets/vite.svg | 1 + .../src/components/chartr-preview.tsx | 238 ++ .../src/components/color-token-field.tsx | 159 + .../src/components/ui/button.tsx | 34 + .../src/components/ui/dialog.tsx | 29 + .../src/components/ui/input.tsx | 17 + .../src/components/ui/popover.tsx | 25 + .../src/components/ui/select.tsx | 57 + .../src/components/ui/tabs.tsx | 17 + .../src/components/ui/tooltip.tsx | 13 + misc/theme-playground/src/index.css | 59 + misc/theme-playground/src/lib/themes.ts | 198 ++ misc/theme-playground/src/lib/utils.ts | 6 + misc/theme-playground/src/main.tsx | 10 + misc/theme-playground/tsconfig.app.json | 29 + misc/theme-playground/tsconfig.json | 12 + misc/theme-playground/tsconfig.node.json | 23 + misc/theme-playground/vite.config.ts | 13 + 31 files changed, 4811 insertions(+) create mode 100644 misc/theme-playground/.gitignore create mode 100644 misc/theme-playground/.oxlintrc.json create mode 100644 misc/theme-playground/README.md create mode 100644 misc/theme-playground/components.json create mode 100644 misc/theme-playground/index.html create mode 100644 misc/theme-playground/package-lock.json create mode 100644 misc/theme-playground/package.json create mode 100644 misc/theme-playground/public/favicon.svg create mode 100644 misc/theme-playground/public/icons.svg create mode 100644 misc/theme-playground/src/App.css create mode 100644 misc/theme-playground/src/App.tsx create mode 100644 misc/theme-playground/src/assets/hero.png create mode 100644 misc/theme-playground/src/assets/react.svg create mode 100644 misc/theme-playground/src/assets/vite.svg create mode 100644 misc/theme-playground/src/components/chartr-preview.tsx create mode 100644 misc/theme-playground/src/components/color-token-field.tsx create mode 100644 misc/theme-playground/src/components/ui/button.tsx create mode 100644 misc/theme-playground/src/components/ui/dialog.tsx create mode 100644 misc/theme-playground/src/components/ui/input.tsx create mode 100644 misc/theme-playground/src/components/ui/popover.tsx create mode 100644 misc/theme-playground/src/components/ui/select.tsx create mode 100644 misc/theme-playground/src/components/ui/tabs.tsx create mode 100644 misc/theme-playground/src/components/ui/tooltip.tsx create mode 100644 misc/theme-playground/src/index.css create mode 100644 misc/theme-playground/src/lib/themes.ts create mode 100644 misc/theme-playground/src/lib/utils.ts create mode 100644 misc/theme-playground/src/main.tsx create mode 100644 misc/theme-playground/tsconfig.app.json create mode 100644 misc/theme-playground/tsconfig.json create mode 100644 misc/theme-playground/tsconfig.node.json create mode 100644 misc/theme-playground/vite.config.ts diff --git a/misc/theme-playground/.gitignore b/misc/theme-playground/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/misc/theme-playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/misc/theme-playground/.oxlintrc.json b/misc/theme-playground/.oxlintrc.json new file mode 100644 index 00000000..6fa991da --- /dev/null +++ b/misc/theme-playground/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/misc/theme-playground/README.md b/misc/theme-playground/README.md new file mode 100644 index 00000000..28f2047d --- /dev/null +++ b/misc/theme-playground/README.md @@ -0,0 +1,36 @@ +# Chartr Theme Playground + +A local theme builder for Chartr's native GPUI app. It mirrors the palette model in +`crates/zeddy/src/settings.rs`, including Chartr's four sidebar-only colors. + +## Run it + +```sh +npm install +npm run dev +``` + +Use `npm run build` for a production build. + +## Theme workflow + +1. Pick one of the 15 themes currently registered by Chartr. +2. Edit a `0xrrggbb` field directly, or click its swatch for HSV and RGB controls. +3. Check the Workspace, Settings, and UI states previews. +4. Use the download button to save a portable JSON draft. The import button restores it later. +5. Choose **Export to Rust** and copy or download the generated Rust entries. +6. Add the `ThemePalette::new(...)` entry to `THEME_PALETTES` and the + `SidebarThemePalette::new(...)` entry to `SIDEBAR_THEME_PALETTES` in + `crates/zeddy/src/settings.rs`. Increment both fixed array lengths. + +`init_themes` already registers both arrays, so the new theme appears in Chartr on the next build. + +Draft edits are also saved automatically in browser local storage. + +## Token coverage + +The playground exposes the 16 fields in `ThemePalette` and the four fields in +`SidebarThemePalette`: card inactive, card active, session hover, and session active. + +The preset values are intentionally kept in `src/lib/themes.ts` in the same order as the +Rust catalog, followed by Chartr Dark and Chartr Light. diff --git a/misc/theme-playground/components.json b/misc/theme-playground/components.json new file mode 100644 index 00000000..0cb39270 --- /dev/null +++ b/misc/theme-playground/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} diff --git a/misc/theme-playground/index.html b/misc/theme-playground/index.html new file mode 100644 index 00000000..8f47cc38 --- /dev/null +++ b/misc/theme-playground/index.html @@ -0,0 +1,14 @@ + + + + + + + + Chartr Theme Playground + + +

+ + + diff --git a/misc/theme-playground/package-lock.json b/misc/theme-playground/package-lock.json new file mode 100644 index 00000000..4d00ee8c --- /dev/null +++ b/misc/theme-playground/package-lock.json @@ -0,0 +1,2797 @@ +{ + "name": "theme-playground", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "theme-playground", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.39.0", + "react": "^19.2.8", + "react-colorful": "^5.8.1", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.39.0.tgz", + "integrity": "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-colorful": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz", + "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/misc/theme-playground/package.json b/misc/theme-playground/package.json new file mode 100644 index 00000000..26aaf2e0 --- /dev/null +++ b/misc/theme-playground/package.json @@ -0,0 +1,37 @@ +{ + "name": "theme-playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.39.0", + "react": "^19.2.8", + "react-colorful": "^5.8.1", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } +} diff --git a/misc/theme-playground/public/favicon.svg b/misc/theme-playground/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/misc/theme-playground/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/misc/theme-playground/public/icons.svg b/misc/theme-playground/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/misc/theme-playground/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/misc/theme-playground/src/App.css b/misc/theme-playground/src/App.css new file mode 100644 index 00000000..deae575b --- /dev/null +++ b/misc/theme-playground/src/App.css @@ -0,0 +1,735 @@ +.playground-shell { + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + max-height: 100dvh; + min-height: 0; + display: grid; + grid-template-columns: 390px minmax(0, 1fr); + overflow: hidden; + background: #111318; +} + +.builder-sidebar { + min-width: 0; + min-height: 0; + height: 100%; + max-height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + color: #f0f1f3; + background: #191b21; + border-right: 1px solid #30333b; + box-shadow: 12px 0 32px rgb(0 0 0 / 16%); + z-index: 5; +} + +.builder-brand { + height: 72px; + display: flex; + align-items: center; + gap: 11px; + padding: 0 18px; + border-bottom: 1px solid #2b2e36; + background: #1b1d23; +} + +.brand-mark { + width: 35px; + height: 35px; + display: grid; + place-items: center; + color: #dce2eb; + border: 1px solid #454a56; + border-radius: 9px; + background: linear-gradient(145deg, #343945, #252830); + box-shadow: inset 0 1px rgb(255 255 255 / 6%), 0 4px 12px rgb(0 0 0 / 22%); +} + +.brand-mark svg { width: 17px; } +.builder-brand > div:nth-child(2) { display: grid; gap: 1px; min-width: 0; } +.builder-brand strong { font-size: 13.5px; letter-spacing: -.01em; } +.builder-brand span { font-size: 11px; color: #8d929d; } +.builder-brand .beta-badge { + margin-left: auto; + padding: 3px 6px; + font: 650 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .08em; + color: #aeb5c1; + background: #272a32; + border: 1px solid #3a3e48; + border-radius: 4px; +} + +.builder-scroll { + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-color: #414550 transparent; + scrollbar-width: thin; +} + +.preset-section { padding: 20px 18px 18px; border-bottom: 1px solid #2b2e36; } +.section-kicker, .token-intro > div { display: flex; align-items: center; margin-bottom: 9px; } +.section-kicker > span, .token-intro span { + font-size: 9.5px; + font-weight: 700; + letter-spacing: .12em; + color: #858b97; +} + +.section-kicker i { + margin-left: auto; + padding: 2px 6px; + color: #d2aa68; + background: #3b3021; + border: 1px solid #574328; + border-radius: 10px; + font-size: 9px; + font-style: normal; +} + +.preset-trigger { height: 42px; border-color: #3b3f49; background: #20232a; } +.preset-option { width: 100%; min-width: 246px; display: flex; align-items: center; gap: 9px; } +.preset-option > i { + position: relative; + width: 22px; + height: 22px; + flex: 0 0 auto; + border: 1px solid; + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 5%); +} +.preset-option > i b { position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border-radius: 50%; } +.preset-option em { margin-left: auto; color: #858b97; font-size: 10px; font-style: normal; } + +.identity-grid { display: grid; grid-template-columns: 1.35fr 1fr; gap: 10px; margin-top: 13px; } +.identity-grid label { display: grid; gap: 6px; } +.identity-grid label > span { font-size: 10px; font-weight: 600; color: #9da2ad; } +.identity-grid input { height: 34px; padding-inline: 9px; border-color: #383c46; background: #1f2229; font-size: 11.5px; } +.appearance-toggle { height: 34px; display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border: 1px solid #383c46; border-radius: 6px; background: #1f2229; } +.appearance-toggle button { border: 0; border-radius: 4px; background: transparent; color: #858b97; font-size: 10.5px; cursor: pointer; } +.appearance-toggle button.active { color: #eff0f2; background: #343842; box-shadow: 0 1px 2px rgb(0 0 0 / 25%); } + +.token-intro { padding: 18px 18px 14px; } +.token-intro > div { margin: 0 0 5px; } +.token-intro b, .token-group-title > b { + margin-left: auto; + min-width: 20px; + height: 18px; + display: grid; + place-items: center; + color: #a3a8b2; + background: #282b33; + border-radius: 9px; + font-size: 9px; +} +.token-intro p { margin: 0; max-width: 310px; color: #7f8590; font-size: 10.5px; line-height: 1.45; } + +.token-group { margin: 0 12px 17px; overflow: hidden; border: 1px solid #2f323b; border-radius: 9px; background: #1c1e24; } +.token-group-title { display: flex; align-items: center; min-height: 51px; padding: 9px 11px 8px; border-bottom: 1px solid #2c2f37; background: #202229; } +.token-group-title > div { display: grid; gap: 2px; } +.token-group-title strong { color: #d3d5da; font-size: 11.5px; font-weight: 630; } +.token-group-title span { color: #767c87; font-size: 9.5px; } +.token-group-title > b { margin-right: 1px; } +.token-list { padding: 3px 0; } +.token-row { min-height: 43px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px 6px 11px; } +.token-row + .token-row { border-top: 1px solid #272a31; } +.token-row:hover { background: #20232a; } +.token-meta { min-width: 0; display: flex; align-items: center; gap: 5px; } +.token-meta > span { overflow: hidden; color: #b9bdc5; font-size: 10.5px; white-space: nowrap; text-overflow: ellipsis; } +.token-info { width: 14px; height: 14px; padding: 0; border: 0; background: none; color: #5f6570; opacity: 0; cursor: help; } +.token-info svg { width: 12px; height: 12px; } +.token-row:hover .token-info, .token-info:focus-visible { opacity: 1; } +.token-control { display: flex; align-items: center; gap: 6px; } +.color-swatch { + width: 26px; + height: 26px; + flex: 0 0 auto; + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 6px; + cursor: pointer; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 18%), 0 1px 2px rgb(0 0 0 / 18%); +} +.color-swatch:focus-visible { outline: 2px solid #99a2b1; outline-offset: 2px; } +.token-input { width: 83px; height: 27px; padding: 0 7px; border-color: #343842; background: #181a20; color: #c6c9d0; font: 10.5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: -.02em; } + +.builder-actions { min-height: 64px; display: flex; align-items: center; gap: 7px; padding: 11px 12px; border-top: 1px solid #30333b; background: #1b1d23; } +.builder-actions > button { height: 37px; border-color: #393d47; background: #20232a; } +.builder-actions > button:hover { background: #2a2e37; } +.builder-actions .export-button { flex: 1; color: #17191f; background: #eceef1; border-color: #eceef1; } +.builder-actions .export-button:hover { background: #fff; } + +.color-popover { width: 310px; padding: 13px; } +.picker-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 12px; } +.picker-heading > div { display: grid; gap: 2px; } +.picker-heading strong { font-size: 12px; } +.picker-heading span { color: #9298a4; font-size: 10px; } +.picker-heading code { padding: 4px 6px; color: #c7cad1; background: #17191e; border: 1px solid #353944; border-radius: 5px; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; } +.picker-tabs { width: 100%; margin-bottom: 10px; } +.picker-tabs > button { flex: 1; } +.picker-panel { margin: 0; } +.color-popover .react-colorful { + width: 100%; + height: 194px; + touch-action: none; + user-select: none; +} +.color-popover .react-colorful__saturation { border-radius: 7px 7px 4px 4px; } +.color-popover .react-colorful__hue { height: 17px; margin-top: 9px; border-radius: 8px; } +.color-popover .react-colorful__pointer { width: 19px; height: 19px; border-width: 2px; box-shadow: 0 1px 4px rgb(0 0 0 / 60%); } +.hsv-readout { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 10px; } +.hsv-readout span { display: flex; justify-content: space-between; padding: 6px 7px; color: #818793; background: #191b21; border: 1px solid #343842; border-radius: 5px; font-size: 9.5px; } +.hsv-readout b { color: #d5d7dc; font-weight: 500; } +.rgb-panel { display: grid; gap: 11px; padding-top: 3px; } +.rgb-row { display: grid; grid-template-columns: 14px 1fr 54px; align-items: center; gap: 8px; } +.rgb-row label { color: #8b919c; font-size: 10px; font-weight: 700; } +.rgb-row input[type="number"] { height: 30px; padding: 0 5px; text-align: center; font-size: 10px; } +.channel-slider { width: 100%; height: 5px; appearance: none; border-radius: 3px; outline: none; } +.channel-slider::-webkit-slider-thumb { width: 14px; height: 14px; appearance: none; border: 2px solid white; border-radius: 50%; background: #ddd; box-shadow: 0 1px 3px #000; cursor: pointer; } +.channel-r { background: linear-gradient(90deg, #100, #f33); } +.channel-g { background: linear-gradient(90deg, #010, #3f3); } +.channel-b { background: linear-gradient(90deg, #001, #33f); } +.rgb-preview { height: 43px; border: 1px solid rgb(255 255 255 / 15%); border-radius: 7px; box-shadow: inset 0 0 0 1px rgb(0 0 0 / 20%); } + +.preview-area { + min-width: 0; + min-height: 0; + height: 100%; + max-height: 100%; + display: grid; + grid-template-rows: 61px minmax(0, 1fr); + overflow: hidden; + color: #ccd0d7; + background: + radial-gradient(circle at 60% 42%, rgb(69 75 88 / 16%), transparent 32%), + linear-gradient(140deg, #17191e, #101216 68%); +} + +.playground-topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 27px; border-bottom: 1px solid #292c33; background: rgb(20 22 27 / 92%); } +.playground-topbar > div:first-child { display: flex; align-items: center; gap: 9px; min-width: 0; } +.playground-topbar span { color: #717782; font-size: 9px; font-weight: 700; letter-spacing: .13em; } +.playground-topbar strong { overflow: hidden; color: #d5d8dd; font-size: 12.5px; font-weight: 570; white-space: nowrap; text-overflow: ellipsis; } +.playground-topbar > div:first-child i { padding: 3px 6px; color: #8e949f; background: #252830; border: 1px solid #343842; border-radius: 10px; font-size: 8.5px; font-style: normal; } +.palette-strip { display: flex; overflow: hidden; border: 1px solid #383c45; border-radius: 6px; box-shadow: 0 2px 7px rgb(0 0 0 / 24%); } +.palette-strip i { width: 19px; height: 19px; border-right: 1px solid rgb(0 0 0 / 16%); } +.palette-strip i:last-child { border: 0; } + +.preview-tabs { min-height: 0; display: grid; grid-template-rows: 54px minmax(0, 1fr); overflow: hidden; } +.preview-bar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 26px; } +.preview-bar > [role="tablist"] { background: #1c1f25; border: 1px solid #30343d; } +.preview-bar > [role="tablist"] button { color: #7f8590; } +.preview-bar > [role="tablist"] button[data-state="active"] { color: #e4e6e9; background: #323640; } +.preview-label { display: flex; align-items: center; gap: 6px; color: #6e747f; font-size: 9px; font-weight: 700; letter-spacing: .12em; } +.preview-label i { width: 6px; height: 6px; background: #6e9870; border-radius: 50%; box-shadow: 0 0 0 3px rgb(110 152 112 / 12%); } +.preview-stage { + min-width: 0; + min-height: 0; + align-self: stretch; + margin: 0; + display: flex; + align-items: flex-start; + justify-content: center; + overflow-x: auto; + overflow-y: hidden; + padding: 22px 26px 30px; +} + +.native-window { + width: min(100%, 1280px); + min-width: 720px; + height: 760px; + max-height: 100%; + min-height: 0; + flex: 0 1 auto; + overflow: hidden; + color: var(--t-text); + background: var(--t-surface); + border: 1px solid color-mix(in srgb, var(--t-border) 90%, #000); + border-radius: 11px; + box-shadow: 0 30px 80px rgb(0 0 0 / 43%), 0 3px 14px rgb(0 0 0 / 28%); + font-family: "IBM Plex Sans", Inter, ui-sans-serif, sans-serif; +} + +.native-window button { font-family: inherit; } +.native-titlebar { height: 43px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 13px; background: var(--t-sidebar); border-bottom: 1px solid var(--t-border); } +.traffic-lights { display: flex; gap: 7px; } +.traffic-lights i { width: 10px; height: 10px; border-radius: 50%; background: var(--t-border); } +.traffic-lights i:first-child { background: var(--t-notice); } +.traffic-lights i:nth-child(2) { background: var(--t-idle); } +.traffic-lights i:last-child { background: var(--t-done); } +.window-title { display: flex; align-items: center; gap: 6px; color: var(--t-muted); font-size: 10.5px; font-weight: 550; } +.window-title svg { width: 12px; } +.title-actions { justify-self: end; display: flex; gap: 14px; color: var(--t-muted); } +.title-actions svg { width: 13px; height: 13px; } +.native-body { height: calc(100% - 43px); display: grid; grid-template-columns: 226px minmax(0, 1fr); } +.native-sidebar { position: relative; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 10px 9px 33px; background: var(--t-sidebar); border-right: 1px solid var(--t-border); } +.sidebar-head { min-height: 23px; display: flex; align-items: center; justify-content: space-between; padding: 0 6px; color: var(--t-muted); font-size: 8px; font-weight: 700; letter-spacing: .11em; } +.sidebar-head > div { display: flex; gap: 10px; } +.sidebar-head svg { width: 12px; height: 12px; } +.space-card { overflow: hidden; border: 1px solid var(--t-border); border-radius: 6px; } +.space-card.active { background: var(--t-side-card-active); } +.space-card.inactive { background: var(--t-side-card-inactive); } +.space-title { height: 33px; display: flex; align-items: center; gap: 6px; padding: 0 8px; color: var(--t-muted); font-size: 8.5px; } +.space-title > svg:first-child { width: 13px; color: var(--t-accent); } +.space-title svg { width: 10px; height: 10px; } +.space-title svg:last-child { margin-left: auto; } +.space-title strong { color: var(--t-text); font-size: 9.5px; font-weight: 600; } +.session-list { padding: 0 3px 4px; } +.session-row { width: 100%; height: 29px; display: flex; align-items: center; gap: 7px; padding: 0 7px; border: 0; border-radius: 4px; background: transparent; color: var(--t-muted); font-size: 9px; text-align: left; cursor: pointer; } +.session-row:hover { background: var(--t-side-session-hover); } +.session-row.active { color: var(--t-text); background: var(--t-side-session-active); } +.session-row svg { width: 11px; height: 11px; } +.session-row span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.status-dot { width: 5px; height: 5px; flex: 0 0 auto; margin-left: auto; border-radius: 50%; background: var(--t-quiet); } +.status-dot.done { background: var(--t-done); } +.status-dot.idle { background: var(--t-idle); } +.status-dot.notice { background: var(--t-notice); } +.status-dot.quiet { background: var(--t-quiet); } +.sidebar-foot { position: absolute; left: 0; right: 0; bottom: 0; height: 28px; display: flex; align-items: center; gap: 6px; padding: 0 13px; color: var(--t-quiet); border-top: 1px solid var(--t-border); font-size: 8.5px; } +.sidebar-foot svg { width: 7px; fill: var(--t-done); color: var(--t-done); } + +.native-main { min-width: 0; min-height: 0; display: grid; grid-template-rows: 35px minmax(0, 1fr) 24px; } +.workspace-tabs, .pane-tabs { display: flex; align-items: stretch; min-width: 0; background: var(--t-card); border-bottom: 1px solid var(--t-border); } +.workspace-tab { min-width: 0; width: 145px; display: flex; align-items: center; justify-content: center; gap: 6px; border: 0; border-right: 1px solid var(--t-border); background: transparent; color: var(--t-muted); font-size: 9px; } +.workspace-tab.active { color: var(--t-text); background: var(--t-surface); box-shadow: inset 0 2px var(--t-ring); } +.workspace-tab svg { width: 10px; height: 10px; } +.workspace-tab svg:last-child { margin-left: 7px; width: 8px; opacity: .55; } +.workspace-add { width: 35px; border: 0; background: transparent; color: var(--t-muted); } +.workspace-add svg { width: 11px; } +.workspace-tab:hover, .workspace-add:hover { background: var(--t-hover); } +.native-pane { min-height: 0; display: grid; grid-template-rows: 31px minmax(0, 1fr); } +.pane-tabs { background: var(--t-surface); } +.pane-tab { min-width: 145px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; color: var(--t-muted); background: var(--t-card); border: 0; border-right: 1px solid var(--t-border); font-size: 8.5px; } +.pane-tab.active { color: var(--t-text); background: var(--t-surface); } +.pane-tab svg { width: 10px; height: 10px; } +.pane-tab svg:last-child { width: 8px; margin-left: auto; } +.pane-action { width: 30px; margin-left: auto; border: 0; background: transparent; color: var(--t-muted); } +.pane-action + .pane-action { margin-left: 0; } +.pane-action svg { width: 11px; } +.terminal-content { padding: 19px 22px; overflow: hidden; color: var(--t-terminal); background: var(--t-surface); font: 10.5px/1.65 "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } +.term-muted { color: var(--t-muted); } +.term-accent { color: var(--t-accent); } +.term-done { color: var(--t-done); } +.term-notice { color: var(--t-notice); } +.term-branch { color: var(--t-idle); } +.term-prompt { color: var(--t-accent); font-weight: 600; } +.term-caret { color: var(--t-done); } +.term-gap { height: 12px; } +.terminal-cursor { display: inline-block; width: 6px; height: 13px; margin-left: 4px; vertical-align: -2px; background: var(--t-text); opacity: .75; } +.statusbar { display: flex; align-items: center; gap: 14px; padding: 0 10px; color: var(--t-muted); background: var(--t-sidebar); border-top: 1px solid var(--t-border); font-size: 8px; } +.statusbar span { display: flex; align-items: center; gap: 4px; } +.statusbar svg { width: 9px; } +.statusbar .status-spacer { flex: 1; } + +/* The workspace preview mirrors Chartr's actual multi-agent pane layout. */ +.workspace-window { + --chartr-titlebar-height: 34px; + --chartr-tab-height: 32px; + height: 760px; + max-height: 100%; + min-height: 0; + background: var(--t-sidebar); +} + +.workspace-titlebar { + grid-template-columns: 1fr auto 1fr; + height: var(--chartr-titlebar-height); + padding-inline: 11px; + background: var(--t-sidebar); +} + +.workspace-titlebar .traffic-lights i, +.showcase-titlebar .traffic-lights i { background: #ff5f57; } +.workspace-titlebar .traffic-lights i:nth-child(2), +.showcase-titlebar .traffic-lights i:nth-child(2) { background: #febc2e; } +.workspace-titlebar .traffic-lights i:nth-child(3), +.showcase-titlebar .traffic-lights i:nth-child(3) { background: #28c840; } + +.workspace-titlebar > svg { + width: 12px; + height: 12px; + justify-self: end; + color: var(--t-muted); +} + +.workspace-title { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.workspace-title .traffic-lights { margin-right: 2px; } +.workspace-title strong { color: var(--t-text); font-size: 10.5px; font-weight: 580; } +.workspace-title > span { color: var(--t-muted); font-size: 11px; } + +.workspace-native-body { + height: calc(100% - var(--chartr-titlebar-height)); + min-height: 0; + display: grid; + grid-template-columns: 18.5% minmax(0, 1fr); +} + +.workspace-native-sidebar { + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 6px 8px; + color: var(--t-text); + background: var(--t-sidebar); + border-right: 1px solid var(--t-border); +} + +.free-session-head { + height: 27px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4px 2px; + color: var(--t-muted); + font-size: 10px; + font-weight: 570; +} + +.free-session-head svg { width: 11px; height: 11px; } +.free-session { + width: 100%; + height: 27px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px; + color: var(--t-text); + background: transparent; + border: 0; + border-radius: 4px; + font-size: 12px; + font-weight: 550; + text-align: left; +} + +.free-session:hover { background: var(--t-side-session-hover); } +.free-session.active { background: var(--t-side-session-active); } +.free-session i, .workspace-space-card p > i { + width: 5px; + height: 5px; + flex: 0 0 auto; + background: var(--t-muted); + border-radius: 50%; +} + +.workspace-space-card { + width: 100%; + display: block; + padding: 4px; + overflow: hidden; + color: var(--t-text); + background: var(--t-side-card-inactive); + border: 1px solid transparent; + border-radius: 6px; + text-align: left; +} + +.workspace-space-card.inactive { background: var(--t-side-card-inactive); } +.free-space-card { flex: 0 0 auto; } + +.workspace-space-card.active { + background: var(--t-side-card-active); + border-color: var(--t-ring); +} + +.workspace-space-card > div { + height: 27px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 3px; +} + +.workspace-space-card > div strong { font-size: 10px; font-weight: 580; } +.workspace-space-card > div span { display: flex; gap: 9px; color: var(--t-muted); } +.workspace-space-card > div svg { width: 10px; height: 10px; } +.workspace-space-card p { + height: 27px; + display: flex; + align-items: center; + gap: 7px; + margin: 0; + padding: 0 7px; + color: var(--t-muted); + border-radius: 4px; + font-size: 12px; + font-weight: 560; +} + +.workspace-space-card p > svg:first-child { width: 11px; height: 11px; } +.workspace-space-card p > svg:last-child { width: 9px; height: 9px; margin-left: auto; } +.workspace-space-card .sidebar-group-active { color: var(--t-text); background: var(--t-side-session-active); } +.workspace-space-card .sidebar-session-hover { color: var(--t-text); background: var(--t-side-session-hover); } + +.agent-workspace { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: 43.5% minmax(0, 56.5%); + grid-template-rows: 46% 54%; + background: var(--t-surface); +} + +.agent-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: var(--chartr-tab-height) minmax(0, 1fr); overflow: hidden; } +.claude-pane { grid-row: 1 / 3; border-right: 1px solid var(--t-border); } +.opencode-pane { border-bottom: 1px solid var(--t-border); } + +.agent-pane-tabs { + min-width: 0; + display: flex; + align-items: stretch; + background: var(--t-card); + border-bottom: 1px solid var(--t-border); +} + +.agent-pane-tab { + min-width: 58px; + max-width: 160px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 4px; + color: var(--t-muted); + background: var(--t-card); + border: 0; + border-right: 1px solid var(--t-border); + font-size: 12px; + font-weight: 580; +} + +.agent-pane-tab.active { + position: relative; + z-index: 1; + margin-bottom: -1px; + color: var(--t-text); + background: var(--t-surface); + border-bottom: 1px solid var(--t-surface); +} +.agent-pane-tab i { width: 6px; height: 6px; flex: 0 0 auto; margin-inline: 3px; background: var(--t-quiet); border-radius: 50%; } +.agent-pane-tab span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.agent-pane-tab svg { width: 12px; height: 12px; margin: 0 1px 0 auto; } +.agent-pane-add { width: 32px; flex: 0 0 auto; margin-left: -1px; color: var(--t-muted); background: var(--t-card); border: 0; border-left: 1px solid var(--t-border); border-right: 1px solid var(--t-border); } +.agent-pane-add svg { width: 14px; height: 14px; } + +.shell-terminal { + min-height: 0; + position: relative; + overflow: hidden; + color: var(--t-terminal); + padding: 8px 9px; + background: var(--t-surface); + font: 500 12px/1.23 "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: -.015em; +} + +.shell-terminal p { + margin: 0 0 14px; +} + +.shell-cursor { + display: inline-block; + width: 6px; + height: 13px; + margin-left: 3px; + vertical-align: -2px; + border: 1px solid #1688ff; + font-style: normal; +} + +.shell-cursor.solid { + background: #3b89e8; +} + +.terminal-pane .shell-terminal { + color: var(--t-terminal); + background: var(--t-surface); + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.settings-body { height: calc(100% - 43px); display: grid; grid-template-columns: 205px minmax(0, 1fr); } +.settings-nav { padding: 13px 10px; background: var(--t-sidebar); border-right: 1px solid var(--t-border); } +.settings-search { height: 30px; display: flex; align-items: center; gap: 7px; margin-bottom: 12px; padding: 0 9px; color: var(--t-muted); background: var(--t-card); border: 1px solid var(--t-border); border-radius: 5px; font-size: 9px; } +.settings-search svg { width: 11px; } +.settings-nav button { width: 100%; height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 9px; color: var(--t-muted); background: transparent; border: 0; border-radius: 4px; font-size: 9px; text-align: left; } +.settings-nav button.active { color: var(--t-text); background: var(--t-selected); } +.settings-nav button:hover { background: var(--t-hover); } +.settings-nav button svg { width: 10px; height: 10px; } +.settings-main { padding: 28px 35px; overflow: auto; background: var(--t-surface); } +.settings-header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 23px; } +.settings-header > div { display: grid; gap: 3px; } +.settings-header > div span { color: var(--t-muted); font-size: 7.5px; font-weight: 700; letter-spacing: .12em; } +.settings-header h2 { margin: 0; color: var(--t-text); font-size: 18px; font-weight: 560; } +.settings-header > span { color: var(--t-quiet); font-size: 8px; } +.setting-group { margin-bottom: 16px; overflow: hidden; border: 1px solid var(--t-border); border-radius: 7px; background: var(--t-card); } +.setting-row { min-height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 11px 14px; } +.setting-row + .setting-row { border-top: 1px solid var(--t-border); } +.setting-row > div:first-child { display: grid; gap: 3px; } +.setting-row strong { color: var(--t-text); font-size: 9.5px; font-weight: 590; } +.setting-row span { color: var(--t-muted); font-size: 8px; } +.segmented { display: flex; padding: 3px; background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; } +.segmented button { width: 52px; height: 24px; color: var(--t-muted); background: transparent; border: 0; border-radius: 3px; font-size: 8px; } +.segmented button.active { color: var(--t-text); background: var(--t-selected); } +.native-select { min-width: 160px; height: 31px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 9px; color: var(--t-text); background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; font-size: 8.5px; } +.native-select svg { width: 10px; } +.stepper { display: flex; align-items: center; overflow: hidden; background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; } +.stepper button, .stepper span { width: 27px; height: 27px; display: grid; place-items: center; border: 0; color: var(--t-muted); background: transparent; font-size: 10px; } +.stepper span { color: var(--t-text); border-inline: 1px solid var(--t-border); } +.native-switch { width: 31px; height: 18px; padding: 2px; border: 1px solid var(--t-border); border-radius: 10px; background: var(--t-hover); } +.native-switch i { display: block; width: 12px; height: 12px; background: var(--t-muted); border-radius: 50%; } + +.component-stage { align-items: stretch; } +.component-showcase { + width: min(100%, 1040px); + min-width: 720px; + height: 100%; + min-height: 0; + overflow: auto; + color: var(--t-text); + background: var(--t-surface); + border: 1px solid var(--t-border); + border-radius: 10px; + box-shadow: 0 26px 70px rgb(0 0 0 / 34%); + font-family: "IBM Plex Sans", Inter, ui-sans-serif, sans-serif; + scrollbar-color: var(--t-border) transparent; + scrollbar-width: thin; +} +.showcase-titlebar { + position: sticky; + top: 0; + z-index: 2; + height: 42px; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 11px; + padding: 0 13px; + background: var(--t-sidebar); + border-bottom: 1px solid var(--t-border); +} +.showcase-titlebar > div:nth-child(2) { display: flex; align-items: baseline; gap: 8px; } +.showcase-titlebar strong { font-size: 10px; font-weight: 620; } +.showcase-titlebar span { color: var(--t-muted); font-size: 8px; } +.showcase-titlebar > b { padding: 3px 6px; color: var(--t-quiet); border: 1px solid var(--t-border); border-radius: 4px; font: 650 7px/1 "IBM Plex Mono", monospace; letter-spacing: .08em; } +.showcase-grid { display: grid; grid-template-columns: minmax(260px, .9fr) minmax(340px, 1.1fr); gap: 10px; padding: 12px; } +.specimen-section { min-width: 0; padding: 12px; background: var(--t-sidebar); border: 1px solid var(--t-border); border-radius: 7px; } +.specimen-heading { height: 25px; display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 9px; } +.specimen-heading > div { display: flex; align-items: baseline; gap: 7px; min-width: 0; } +.specimen-heading > div > span { color: var(--t-quiet); font: 600 7px "IBM Plex Mono", monospace; } +.specimen-heading h2 { margin: 0; overflow: hidden; font-size: 10.5px; font-weight: 610; white-space: nowrap; text-overflow: ellipsis; } +.specimen-token { padding: 2px 4px; color: var(--t-muted); background: color-mix(in srgb, var(--t-card) 65%, transparent); border: 1px solid var(--t-border); border-radius: 3px; font: 6.5px/1.2 "IBM Plex Mono", monospace; white-space: nowrap; } + +.sidebar-specimen { grid-row: span 2; } +.mini-sidebar { padding: 6px; background: var(--t-sidebar); border: 1px solid var(--t-border); border-radius: 6px; } +.mini-sidebar-heading { height: 24px; display: flex; align-items: center; justify-content: space-between; padding: 0 5px; color: var(--t-muted); font-size: 8px; } +.mini-sidebar-heading svg { width: 10px; height: 10px; } +.mini-space { margin-top: 5px; padding: 3px; border: 1px solid var(--t-border); border-radius: 5px; } +.mini-space.inactive { display: grid; grid-template-columns: 1fr auto; align-items: center; background: var(--t-side-card-inactive); } +.mini-space.active { background: var(--t-side-card-active); border-color: var(--t-ring); } +.mini-space > div { height: 23px; display: flex; align-items: center; gap: 7px; padding: 0 5px; } +.mini-space > div > i { width: 5px; height: 5px; background: var(--t-quiet); border-radius: 50%; } +.mini-space > div > strong { font-size: 8.5px; font-weight: 580; } +.mini-space > div > svg { width: 10px; height: 10px; margin-left: auto; color: var(--t-muted); } +.mini-space > .specimen-token { margin-right: 4px; } +.mini-space p { height: 26px; display: flex; align-items: center; gap: 7px; margin: 2px 0 0; padding: 0 6px; color: var(--t-muted); border-radius: 4px; font-size: 8px; } +.mini-space p > svg { width: 10px; height: 10px; } +.mini-space p > i { width: 5px; height: 5px; background: var(--t-quiet); border-radius: 50%; } +.mini-space p .specimen-token { margin-left: auto; } +.mini-space p.hover { color: var(--t-text); background: var(--t-side-session-hover); } +.mini-space p.active { color: var(--t-text); background: var(--t-side-session-active); } + +.mini-pane { overflow: hidden; background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; } +.mini-tabs { height: 32px; display: flex; align-items: stretch; background: var(--t-card); border-bottom: 1px solid var(--t-border); } +.mini-tabs button { min-width: 58px; display: flex; align-items: center; gap: 4px; padding: 0 4px; color: var(--t-muted); background: var(--t-card); border: 0; border-right: 1px solid var(--t-border); font-size: 12px; } +.mini-tabs button i { width: 4px; height: 4px; background: var(--t-quiet); border-radius: 50%; } +.mini-tabs button svg { width: 8px; height: 8px; margin-left: auto; } +.mini-tabs button.open { position: relative; z-index: 1; margin-bottom: -1px; color: var(--t-text); background: var(--t-surface); border-bottom: 1px solid var(--t-surface); } +.mini-tabs button.add { min-width: 28px; width: 28px; } +.mini-terminal { height: 94px; padding: 10px 11px; color: var(--t-terminal); font: 8px/1.4 "IBM Plex Mono", monospace; } +.mini-terminal p { margin: 0 0 7px; } +.mini-terminal span, .mini-terminal em { color: var(--t-muted); font-style: normal; } +.mini-terminal b { color: var(--t-idle); font-weight: 500; } +.mini-terminal strong { color: var(--t-done); } +.mini-terminal p:last-child { display: flex; align-items: center; gap: 5px; } +.mini-terminal p:last-child i { width: 5px; height: 11px; background: var(--t-accent); } +.token-key { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; } + +.interaction-list { display: grid; gap: 5px; } +.interaction-list button { height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 8px; color: var(--t-text); background: var(--t-card); border: 1px solid var(--t-border); border-radius: 5px; font-size: 8px; } +.interaction-list button > span { display: flex; align-items: center; gap: 7px; } +.interaction-list button > span i { width: 5px; height: 5px; background: var(--t-quiet); border-radius: 50%; } +.interaction-list button svg { width: 10px; height: 10px; color: var(--t-muted); } +.interaction-list button.hover { background: var(--t-hover); } +.interaction-list button.pressed { background: var(--t-card-open); } +.interaction-list button.selected { background: var(--t-selected); } +.interaction-list button.focused { outline: 2px solid var(--t-ring); outline-offset: 1px; } + +.type-stack { display: grid; gap: 7px; padding: 9px 10px; background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; } +.type-stack p, .type-stack a { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0; font-size: 8px; } +.type-stack .muted-copy { color: var(--t-muted); } +.type-stack .quiet-copy { color: var(--t-quiet); } +.type-stack a { color: var(--t-accent); } + +.status-summary { display: flex; gap: 4px; padding-top: 3px; } +.status-summary i { width: 6px; height: 6px; border-radius: 50%; background: var(--t-notice); } +.status-summary i:nth-child(2) { background: var(--t-done); } +.status-summary i:nth-child(3) { background: var(--t-idle); } +.semantic-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.semantic { min-width: 0; height: 46px; display: flex; align-items: center; gap: 7px; padding: 0 8px; background: var(--t-card); border: 1px solid var(--t-border); border-left-width: 3px; border-radius: 5px; } +.semantic > svg { width: 11px; height: 11px; flex: 0 0 auto; } +.semantic > div { min-width: 0; display: grid; gap: 1px; } +.semantic strong { overflow: hidden; font-size: 7.5px; font-weight: 590; white-space: nowrap; text-overflow: ellipsis; } +.semantic span { color: var(--t-muted); font-size: 6.5px; } +.semantic .specimen-token { margin-left: auto; } +.semantic.notice { color: var(--t-notice); border-left-color: var(--t-notice); } +.semantic.done { color: var(--t-done); border-left-color: var(--t-done); } +.semantic.idle { color: var(--t-idle); border-left-color: var(--t-idle); } +.semantic.accent { color: var(--t-accent); border-left-color: var(--t-accent); } + +.layer-specimen { grid-column: 1 / -1; } +.layer-stack > div { min-height: 65px; padding: 7px; background: var(--t-surface); border: 1px solid var(--t-border); border-radius: 5px; } +.layer-stack > div > div { height: 49px; margin-top: 6px; padding: 6px; background: var(--t-sidebar); border: 1px solid var(--t-border); border-radius: 4px; } +.layer-stack > div > div > div { height: 32px; margin-top: 5px; padding: 5px; background: var(--t-card); border: 1px solid var(--t-border); border-radius: 3px; } +.layer-stack > div > div > div > div { height: 16px; margin: 3px 0 0 58px; padding: 1px 4px; background: var(--t-card-open); border: 1px solid var(--t-border); border-radius: 2px; } +.layer-note { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--t-muted); font-size: 7px; } + +.export-heading { display: flex; gap: 12px; padding-right: 24px; } +.export-heading > div:last-child { display: grid; gap: 4px; } +.export-icon { width: 38px; height: 38px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid #3d424d; border-radius: 9px; background: #272a32; } +.export-icon svg { width: 17px; } +.export-heading code { color: #c4c8d0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.rust-output { max-height: min(52vh, 520px); margin: 3px 0 0; overflow: auto; padding: 14px; color: #c5d0c8; background: #101216; border: 1px solid #30343d; border-radius: 8px; font: 10.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; scrollbar-width: thin; } +.export-actions { display: flex; justify-content: flex-end; gap: 8px; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 940px) { + html, body, #root { height: auto; min-height: 100%; overflow: auto; } + .playground-shell { position: static; width: 100%; height: auto; max-height: none; min-height: 100vh; grid-template-columns: 1fr; grid-template-rows: min(58vh, 690px) 680px; overflow: visible; } + .builder-sidebar { height: auto; max-height: none; border-right: 0; border-bottom: 1px solid #30333b; } + .preview-area { height: auto; max-height: none; min-height: 680px; } + .preview-stage { overflow: auto; } + .native-window, .workspace-window { height: 520px; min-height: 520px; } +} + +@media (max-width: 620px) { + .playground-shell { grid-template-rows: min(62vh, 720px) 600px; } + .builder-brand { height: 62px; } + .preview-area { min-height: 600px; grid-template-rows: 52px minmax(0, 1fr); } + .playground-topbar { padding-inline: 15px; } + .palette-strip { display: none; } + .preview-bar { padding-inline: 12px; } + .preview-label { display: none; } + .preview-stage { align-items: flex-start; justify-content: center; padding: 12px 12px 20px; } + .identity-grid { grid-template-columns: 1fr 1fr; } +} diff --git a/misc/theme-playground/src/App.tsx b/misc/theme-playground/src/App.tsx new file mode 100644 index 00000000..08f8f8e7 --- /dev/null +++ b/misc/theme-playground/src/App.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { Braces, Check, Copy, Download, FileDown, Import, RotateCcw, Sparkles } from 'lucide-react' +import { ColorTokenField } from '@/components/color-token-field' +import { ChartrPreview } from '@/components/chartr-preview' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + clonePreset, + makeRustExport, + THEME_PRESETS, + TOKEN_GROUPS, + toCssHex, + type Appearance, + type ThemePreset, + type ThemeTokens, + type TokenKey, +} from '@/lib/themes' +import './App.css' + +const STORAGE_KEY = 'chartr-theme-playground:draft-v1' +const DEFAULT_THEME = THEME_PRESETS.find((theme) => theme.name === 'Chartr Dark') ?? THEME_PRESETS[0] + +const loadTheme = (): ThemePreset => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) return JSON.parse(saved) as ThemePreset + } catch { + // Ignore a stale or malformed local draft. + } + return clonePreset(DEFAULT_THEME) +} + +const downloadText = (filename: string, content: string, type: string) => { + const blob = new Blob([content], { type }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} + +const fileSlug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'chartr-theme' + +const PREVIEW_VARIABLES: Record = { + surface: '--t-surface', sidebar: '--t-sidebar', border: '--t-border', text: '--t-text', muted: '--t-muted', card: '--t-card', cardOpen: '--t-card-open', ring: '--t-ring', selected: '--t-selected', hover: '--t-hover', notice: '--t-notice', accent: '--t-accent', done: '--t-done', idle: '--t-idle', quiet: '--t-quiet', terminalForeground: '--t-terminal', sidebarCardInactive: '--t-side-card-inactive', sidebarCardActive: '--t-side-card-active', sidebarSessionHover: '--t-side-session-hover', sidebarSessionActive: '--t-side-session-active', +} + +function App() { + const initialTheme = useMemo(() => loadTheme(), []) + const [theme, setTheme] = useState(initialTheme) + const [sourcePreset, setSourcePreset] = useState(() => THEME_PRESETS.some((preset) => preset.name === initialTheme.name) ? initialTheme.name : 'custom') + const [copied, setCopied] = useState(false) + const fileInput = useRef(null) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(theme)) + }, [theme]) + + const currentPreset = THEME_PRESETS.find((preset) => preset.name === sourcePreset) + const isModified = !currentPreset || JSON.stringify(theme) !== JSON.stringify(currentPreset) + const rustExport = useMemo(() => makeRustExport(theme), [theme]) + const previewStyle = useMemo(() => { + const values = Object.entries(theme.tokens).map(([key, value]) => [PREVIEW_VARIABLES[key as TokenKey], toCssHex(value)]) + return Object.fromEntries(values) as CSSProperties + }, [theme.tokens]) + + const choosePreset = (name: string) => { + const preset = THEME_PRESETS.find((candidate) => candidate.name === name) + if (!preset) return + setTheme(clonePreset(preset)) + setSourcePreset(name) + } + + const updateToken = (key: TokenKey, value: string) => { + setTheme((current) => ({ ...current, tokens: { ...current.tokens, [key]: value } })) + } + + const importTheme = async (file: File) => { + try { + const imported = JSON.parse(await file.text()) as Partial + const hasTokens = imported.tokens && Object.keys(DEFAULT_THEME.tokens).every((key) => typeof imported.tokens?.[key as TokenKey] === 'string') + if (!hasTokens) return + setTheme({ + name: typeof imported.name === 'string' ? imported.name : 'Imported theme', + appearance: imported.appearance === 'Light' ? 'Light' : 'Dark', + tokens: imported.tokens as ThemeTokens, + }) + setSourcePreset('custom') + } finally { + if (fileInput.current) fileInput.current.value = '' + } + } + + const copyRust = async () => { + await navigator.clipboard.writeText(rustExport) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } + + return ( + +
+ + +
+
+
PREVIEWING{theme.name || 'Untitled theme'}{theme.appearance}
+
{['surface', 'sidebar', 'card', 'border', 'muted', 'text', 'accent', 'done', 'idle', 'notice'].map((key) => )}
+
+ +
+
+
+ ) +} + +export default App diff --git a/misc/theme-playground/src/assets/hero.png b/misc/theme-playground/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/misc/theme-playground/src/assets/react.svg b/misc/theme-playground/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/misc/theme-playground/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/misc/theme-playground/src/assets/vite.svg b/misc/theme-playground/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/misc/theme-playground/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/misc/theme-playground/src/components/chartr-preview.tsx b/misc/theme-playground/src/components/chartr-preview.tsx new file mode 100644 index 00000000..c0e6c0c1 --- /dev/null +++ b/misc/theme-playground/src/components/chartr-preview.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react' +import { + AlertTriangle, + Check, + ChevronDown, + Circle, + Cloud, + Code2, + Info, + MoreHorizontal, + Plus, + Search, + SlidersHorizontal, + SquareTerminal, + X, +} from 'lucide-react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +type PaneTabsProps = { tabs: string[]; active?: number } + +function PaneTabs({ tabs, active = 0 }: PaneTabsProps) { + return ( +
+ {tabs.map((tab, index) => ( + + ))} + +
+ ) +} + +function ClaudePane() { + return ( +
+ +
+

rengwu@JGRs-MacBook-Pro bb-chartr %

+
+
+ ) +} + +function OpenCodePane() { + return ( +
+ +
+

rengwu@JGRs-MacBook-Pro bb-chartr % herdr
error: nested herdr is disabled by default.
see configuration if you want to enable it.

+

“recursion is a pathway to many abilities some consider to be... unn
atural.”
rengwu@JGRs-MacBook-Pro bb-chartr %

+
+
+ ) +} + +function GrokPane() { + return ( +
+ +
+

rengwu@JGRs-MacBook-Pro bb-chartr %

+
+
+ ) +} + +function WorkspacePreview() { + const [activeSession, setActiveSession] = useState('group') + return ( +
+
+
bb-chartr
+
+ +
+
+ +
+ + + +
+
+
+ ) +} + +function SettingsPreview() { + return ( +
+
+
+
Settings
+
+
+
+ +
+
SETTINGS

Appearance

Changes save automatically
+
+
Theme modeChoose one theme or match your system.
+
Fixed themeApplied to every Chartr window.
+
+
+
UI fontUsed throughout Chartr's interface.
+
UI font sizeScales controls and interface text.
14
+
Reduce motionMinimize non-essential animation.
+
+
+
+
+ ) +} + +function TokenLabel({ children }: { children: string }) { + return {children} +} + +function ComponentsPreview() { + return ( +
+
+
+
Chartr component statesTheme token specimen
+ 20 TOKENS +
+ +
+
+
01

Sidebar hierarchy

sidebar
+
+
Spaces
+
personal
sidebar_card_inactive
+
+
chartr
+

3 tabs sidebar_card_active

+

review changes sidebar_session_hover

+

theme playground sidebar_session_active

+
+
+
+ +
+
02

Pane chrome

card
+
+
+ + + + +
+
+

~/Projects/chartr main

+

Theme preview ready. Waiting for input…

+

+
+
+
surfacecardborderterminal_foreground
+
+ +
+
03

Interaction states

ring
+
+ + + + + +
+
+ +
+
04

Content contrast

text
+
+
+ +
+
05

Semantic status

+
+
Build failed3 compiler errors
notice
+
Checks passed128 complete
done
+
Runner idleWaiting for work
idle
+
Sync availableReview update
accent
+
+
+ +
+
06

Surface stack

+
+
surface
sidebar
card
card_open
+
+

Boundaries use border throughout.

+
+
+
+ ) +} + +export function ChartrPreview() { + return ( + +
+ + Components + Workspace + Settings + +
LIVE PREVIEW
+
+ + + +
+ ) +} diff --git a/misc/theme-playground/src/components/color-token-field.tsx b/misc/theme-playground/src/components/color-token-field.tsx new file mode 100644 index 00000000..88387c4e --- /dev/null +++ b/misc/theme-playground/src/components/color-token-field.tsx @@ -0,0 +1,159 @@ +import { useMemo, useState } from 'react' +import { Info } from 'lucide-react' +import { HsvColorPicker, type HsvColor } from 'react-colorful' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { normalizeRustHex, toCssHex } from '@/lib/themes' + +type Rgb = { r: number; g: number; b: number } + +const hexToRgb = (hex: string): Rgb => { + const value = Number.parseInt(normalizeRustHex(hex).slice(2), 16) + return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 } +} + +const rgbToHex = ({ r, g, b }: Rgb) => + `0x${[r, g, b].map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0')).join('')}` + +const rgbToHsv = ({ r, g, b }: Rgb): HsvColor => { + const [red, green, blue] = [r, g, b].map((channel) => channel / 255) + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + const delta = max - min + let h = 0 + if (delta) { + if (max === red) h = 60 * (((green - blue) / delta) % 6) + else if (max === green) h = 60 * ((blue - red) / delta + 2) + else h = 60 * ((red - green) / delta + 4) + } + return { h: h < 0 ? h + 360 : h, s: max ? (delta / max) * 100 : 0, v: max * 100 } +} + +const hsvToRgb = ({ h, s, v }: HsvColor): Rgb => { + const saturation = s / 100 + const value = v / 100 + const chroma = value * saturation + const section = h / 60 + const x = chroma * (1 - Math.abs((section % 2) - 1)) + const match = value - chroma + const [r, g, b] = section < 1 ? [chroma, x, 0] : section < 2 ? [x, chroma, 0] : section < 3 ? [0, chroma, x] : section < 4 ? [0, x, chroma] : section < 5 ? [x, 0, chroma] : [chroma, 0, x] + return { r: (r + match) * 255, g: (g + match) * 255, b: (b + match) * 255 } +} + +type ColorTokenFieldProps = { + label: string + hint: string + value: string + onChange: (value: string) => void +} + +export function ColorTokenField({ label, hint, value, onChange }: ColorTokenFieldProps) { + const [draftState, setDraftState] = useState({ source: value, draft: value }) + const draft = draftState.source === value ? draftState.draft : value + const setDraft = (next: string) => setDraftState({ source: value, draft: next }) + const rgb = useMemo(() => hexToRgb(value), [value]) + const valueHsv = useMemo(() => rgbToHsv(rgb), [rgb]) + const [pickerState, setPickerState] = useState({ source: value, color: valueHsv }) + const hsv = pickerState.source === value ? pickerState.color : valueHsv + + const commitDraft = () => { + const normalized = normalizeRustHex(draft, value) + setDraft(normalized) + onChange(normalized) + } + + const updateRgb = (channel: keyof Rgb, next: number) => onChange(rgbToHex({ ...rgb, [channel]: next })) + + const updateHsv = (next: HsvColor) => { + const nextValue = rgbToHex(hsvToRgb(next)) + + // Keep react-colorful on its precise pointer coordinates while dragging. + // Converting the controlled value back through 8-bit RGB on every frame + // introduces rounding drift, which makes the handle visibly jitter. + setPickerState({ source: nextValue, color: next }) + onChange(nextValue) + } + + return ( +
+
+ {label} + + + + + {hint} + +
+
+ + +
+ ) +} diff --git a/misc/theme-playground/src/components/ui/button.tsx b/misc/theme-playground/src/components/ui/button.tsx new file mode 100644 index 00000000..c308aaf6 --- /dev/null +++ b/misc/theme-playground/src/components/ui/button.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + icon: 'size-9', + }, + }, + defaultVariants: { variant: 'default', size: 'default' }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +